feat: add AI markdown reformatting and usage metering to NewsService

Add AI-powered body reformatting for valid articles in the news pipeline, converting raw text walls into clean Markdown with paragraphs, headings, and lists. Introduce `news_usage` database table and `add_news_usage`/`get_news_usage` helpers to track per-cycle gateway costs (calls, tokens, latency, USD) from response headers, reported on the admin Services page. Remove unused `.sidebar-more` CSS and apply `render_title()` to poll question/label fields.
This commit is contained in:
2026-06-18 23:46:53 +00:00
parent 6ceca3d0d4
commit f3a4667fce
13 changed files with 427 additions and 57 deletions
+198 -4
View File
@@ -15,8 +15,15 @@ from PIL import Image
from devplacepy import stealth
from devplacepy import net_guard
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
from devplacepy.database import get_table, get_setting, internal_gateway_key
from devplacepy.database import (
add_news_usage,
get_news_usage,
get_setting,
get_table,
internal_gateway_key,
)
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.openai_gateway.usage import parse_usage_headers
from devplacepy.utils import generate_uid, make_combined_slug, strip_html
from devplacepy.services.audit import record as audit
@@ -27,6 +34,9 @@ AI_URL_DEFAULT = INTERNAL_GATEWAY_URL
AI_MODEL_DEFAULT = INTERNAL_MODEL
GRADE_THRESHOLD_DEFAULT = 7
GRADE_MAX_TOKENS = 2000
FORMAT_MAX_TOKENS = 6000
FORMAT_INPUT_MAX_CHARS = 14000
FORMAT_OUTPUT_MAX_CHARS = 30000
MIN_TITLE_CHARS = 12
MIN_BODY_CHARS = 200
@@ -97,6 +107,28 @@ GRADE_PROMPT_SPEC = (
"Return ONLY a single integer from 1 to 10, nothing else."
)
FORMAT_PROMPT_SPEC = (
"You are an expert editor for DevPlace, a social network for software "
"developers. You are given the raw body of a news article that arrived as "
"an undifferentiated wall of text. Reformat it into clean, readable "
"Markdown for a technical audience.\n\n"
"Rules:\n"
"- Break the text into short, well-structured paragraphs separated by a "
"blank line.\n"
"- Add Markdown section headings (## Heading) where the topic clearly "
"shifts, so the article scans well.\n"
"- Use bullet or numbered lists for enumerations, and inline code or "
"fenced code blocks where code, commands, or identifiers appear.\n"
"- Preserve every fact, name, number, and quotation exactly as given. "
"Never invent, add, remove, or reorder information.\n"
"- Only restructure and lightly polish wording for flow and grammar; do "
"not add an introduction, conclusion, opinion, or commentary of your "
"own.\n"
"- Do not repeat the article title as a heading and do not wrap the whole "
"answer in a code fence.\n"
"- Output only the reformatted article body as Markdown, nothing else."
)
GRADING_RULES_DESCRIPTION = (
"Each run fetches the feed, cleans every article, fetches and perceptually "
"compares images, grades deterministically, and auto-rotates Featured and "
@@ -123,6 +155,12 @@ GRADING_RULES_DESCRIPTION = (
"(Configuration tab), sent to the model at temperature 0 with the cleaned "
"Title, Description and Content appended; it must return a single integer "
"from 1 to 10.\n\n"
"Formatting: after grading, every valid article is reformatted by the AI "
"into clean Markdown (paragraphs, section headings, lists and code spans) "
"using the editable news_format_prompt field, preserving every fact while "
"turning the source wall of text into a readable article. The formatted "
"Markdown replaces the stored content; on any failure the cleaned original "
"is kept. Disable with the news_format_enabled toggle.\n\n"
"Scoring: the AI grade (1-10, temperature 0) is the base. effective_score = "
f"clamp(ai_grade + {UNIQUE_IMAGE_BONUS} if unique image - "
f"{THIN_CONTENT_PENALTY} if the body is marginal but not gated, 1..10). The "
@@ -171,6 +209,31 @@ def _get_ai_key() -> str:
return internal_gateway_key()
USAGE_FIELDS = (
"calls",
"prompt_tokens",
"completion_tokens",
"total_tokens",
"cost_usd",
"upstream_latency_ms",
"total_latency_ms",
)
def _new_usage_totals() -> dict:
return {field: 0 for field in USAGE_FIELDS}
def _accumulate_usage(totals: dict | None, response) -> None:
if totals is None:
return
parsed = parse_usage_headers(getattr(response, "headers", None))
if not parsed:
return
for field in USAGE_FIELDS:
totals[field] += parsed[field]
def _extract_grade(text: str) -> int | None:
match = re.search(r"\d+", text.strip())
if match:
@@ -180,6 +243,16 @@ def _extract_grade(text: str) -> int | None:
return None
def _strip_md_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) < 2 or not lines[-1].strip().startswith("```"):
return stripped
return "\n".join(lines[1:-1]).strip()
def clean_news_text(text: str) -> str:
if not text:
return ""
@@ -383,6 +456,30 @@ class NewsService(BaseService):
help="Defaults to the NEWS_AI_KEY env var, then the gateway's internal key.",
group="AI grading",
),
ConfigField(
"news_format_enabled",
"Reformat content with AI",
type="bool",
default=True,
help=(
"When enabled, every valid article is reformatted into clean "
"Markdown (paragraphs, headings, lists) after grading."
),
group="AI formatting",
),
ConfigField(
"news_format_prompt",
"Formatting prompt specification",
type="text",
default=FORMAT_PROMPT_SPEC,
help=(
"The instruction sent to the AI to reformat each cleaned "
"article into Markdown. The Title and the article body are "
"appended automatically. It must preserve every fact and "
"output only the reformatted Markdown body."
),
group="AI formatting",
),
]
def __init__(self):
@@ -394,6 +491,7 @@ class NewsService(BaseService):
ai_url = config["news_ai_url"]
ai_model = config["news_ai_model"]
threshold = config["news_grade_threshold"]
format_enabled = config["news_format_enabled"]
self.log(f"Fetching news from {api_url}")
async with stealth.stealth_async_client(timeout=30.0) as client:
@@ -422,6 +520,7 @@ class NewsService(BaseService):
failed_count = 0
rejected_count = 0
skipped_count = 0
usage_totals = _new_usage_totals()
async with net_guard.guarded_async_client(
timeout=IMG_FETCH_TIMEOUT
@@ -454,7 +553,7 @@ class NewsService(BaseService):
for article, article_uid, _ in pending:
candidates = candidates_by_article[article_uid]
ai_grade = await self._grade_article(
article, ai_url, ai_model, client
article, ai_url, ai_model, client, usage_totals
)
result = self._grade_article_full(
article, ai_grade, candidates
@@ -481,6 +580,12 @@ class NewsService(BaseService):
and result.effective_score >= FEATURE_MIN_SCORE
)
formatted_content = ""
if result.valid and format_enabled:
formatted_content = await self._format_article(
article, ai_url, ai_model, client, usage_totals
)
saved_new = self._store_article(
news_table,
images_table,
@@ -491,6 +596,7 @@ class NewsService(BaseService):
featured,
threshold,
candidates,
formatted_content,
)
if saved_new:
new_count += 1
@@ -502,12 +608,38 @@ class NewsService(BaseService):
self._apply_landing_selection(news_table, threshold)
if usage_totals["calls"]:
add_news_usage(usage_totals)
self.log(
f"AI usage: {usage_totals['calls']} calls, "
f"{usage_totals['total_tokens']} tokens, "
f"${usage_totals['cost_usd']:.4f}"
)
self.log(
f"New {new_count}, updated {updated_count}, draft {draft_count}, "
f"rejected {rejected_count}, grading failed {failed_count}, "
f"skipped {skipped_count}"
)
def collect_metrics(self) -> dict:
usage = get_news_usage()
stats = [
{"label": "AI calls", "value": usage["calls"]},
{"label": "Total tokens", "value": usage["total_tokens"]},
{"label": "Prompt tokens", "value": usage["prompt_tokens"]},
{"label": "Completion tokens", "value": usage["completion_tokens"]},
{"label": "Total cost", "value": f"${usage['cost_usd']:.4f}"},
{"label": "Avg tokens/call", "value": usage["avg_tokens"]},
{"label": "Avg cost/call", "value": f"${usage['avg_cost_usd']:.6f}"},
{
"label": "Avg latency",
"value": f"{usage['avg_upstream_latency_ms']:.0f}ms",
},
{"label": "Avg tokens/sec", "value": usage["avg_tokens_per_second"]},
]
return {"stats": stats}
def _resolve_uid(self, news_table, external_id: str) -> tuple[str, bool]:
existing = news_table.find_one(external_id=external_id)
if existing:
@@ -575,12 +707,16 @@ class NewsService(BaseService):
featured: bool,
threshold: int,
candidates: list[ImageCandidate],
formatted_content: str = "",
) -> bool:
now = datetime.now(timezone.utc).isoformat()
external_id = article.get("guid", "")
title = clean_news_text(article.get("title", "") or "")[:500] or "news"
description = clean_news_text(article.get("description", "") or "")[:5000]
content = clean_news_text(article.get("content", "") or "")[:10000]
if formatted_content:
content = formatted_content[:FORMAT_OUTPUT_MAX_CHARS]
else:
content = clean_news_text(article.get("content", "") or "")[:10000]
status = "published" if published else "draft"
existing = news_table.find_one(external_id=external_id)
@@ -770,7 +906,12 @@ class NewsService(BaseService):
)
async def _grade_article(
self, article: dict, ai_url: str, ai_model: str, client: httpx.AsyncClient
self,
article: dict,
ai_url: str,
ai_model: str,
client: httpx.AsyncClient,
totals: dict | None = None,
) -> int | None:
title = clean_news_text(article.get("title", "") or "")[:500]
description = clean_news_text(article.get("description", "") or "")[:1000]
@@ -805,6 +946,7 @@ class NewsService(BaseService):
if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status()
_accumulate_usage(totals, resp)
result = resp.json()
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if not text:
@@ -817,3 +959,55 @@ class NewsService(BaseService):
except Exception as e:
self.log(f"AI grading failed for '{title[:50]}': {e}")
return None
async def _format_article(
self,
article: dict,
ai_url: str,
ai_model: str,
client: httpx.AsyncClient,
totals: dict | None = None,
) -> str:
title = clean_news_text(article.get("title", "") or "")[:500]
description = clean_news_text(article.get("description", "") or "")
content = clean_news_text(article.get("content", "") or "")
body = f"{description}\n\n{content}".strip()
if not body:
return ""
body = body[:FORMAT_INPUT_MAX_CHARS]
spec = self.get_config().get("news_format_prompt", "") or FORMAT_PROMPT_SPEC
prompt = f"{spec}\n\nTitle: {title}\n\nArticle:\n{body}"
payload = {
"model": ai_model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": FORMAT_MAX_TOKENS,
"temperature": 0.3,
}
headers = {"Content-Type": "application/json"}
ai_key = _get_ai_key()
if ai_key:
headers["Authorization"] = f"Bearer {ai_key}"
try:
resp = await client.post(
ai_url, json=payload, headers=headers, timeout=60.0
)
if resp.status_code != 200:
self.log(
f"AI formatting returned {resp.status_code}: {resp.text[:200]}"
)
resp.raise_for_status()
_accumulate_usage(totals, resp)
result = resp.json()
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
text = _strip_md_fence(text or "")
if len(text) < MIN_BODY_CHARS:
self.log(f"AI formatting returned too little for: {title[:60]}")
return ""
return text
except Exception as e:
self.log(f"AI formatting failed for '{title[:50]}': {e}")
return ""