Make AI model configurable per feature, and split news grading/formatting models

Every AI-calling feature (content correction, the @ai modifier, quiz grading,
SEO metadata generation, the AI Usage Analyzer, and DeepSearch) can now name
its own model via admin-editable configuration, defaulting to the gateway's
default model when left blank. The gateway a feature talks to stays fixed to
the internal endpoint either way, only the model name is a knob, so the best
model can be picked per task.

Also splits the news service's shared AI grading/formatting config into two
independent endpoint/model/key pairs: reformatting no longer requires
repointing (and thereby breaking) the free, scoring-only grading model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZ5x6KZTxZjbJqsEkxGbxG
This commit is contained in:
2026-09-03 08:47:57 +02:00
co-authored by Claude Sonnet 5
parent 81d6aa68b6
commit 8ae3f628c7
22 changed files with 211 additions and 52 deletions
+3
View File
@@ -695,6 +695,9 @@ class AdminSettingsForm(BaseModel):
privacy_version: str = Field(default="", max_length=20)
guidelines_version: str = Field(default="", max_length=20)
ai_third_party_provider: str = Field(default="", max_length=120)
correction_model: str = Field(default="", max_length=120)
modifier_model: str = Field(default="", max_length=120)
quiz_grading_model: str = Field(default="", max_length=120)
extra_head: str = Field(default="", max_length=50000)
@field_validator("moderation_filter_mode")
+2 -2
View File
@@ -26,7 +26,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
- **Field registry is the single source of truth.** `CORRECTABLE_FIELDS: dict[str, tuple[str, ...]]` maps each correctable table to its prose columns: `posts` -> `(title, content)`, `projects`/`gists` -> `(title, description)`, `comments`/`messages` -> `(content,)`, `users` -> `(bio,)`. `gists.source_code`, project files, and Gitea issues are intentionally excluded - code and external systems are never corrected.
- **Choke entrypoint.** `schedule_correction(user, table, uid, request=None)` is the only thing handlers call (every hook passes `request`). It is a no-op unless: a user dict is present, `table` is in the registry, `user["ai_correction_enabled"]` is truthy, and the user has a non-empty `api_key`. In **sync** mode (`user["ai_correction_sync"]`) it calls `_run_inline_awaited`, which - only when a `request.scope` and a running event loop exist - submits `_run_correction` to `loop.run_in_executor(AI_APPLY_EXECUTOR, ...)` (off the loop thread, on the dedicated AI pool) and stashes the future on `request.scope[PENDING_SCOPE_KEY]` for the middleware to await; if there is no request/loop it returns False and falls back to background. In **background** mode (default) it `background.submit`s `_run_correction` (per-worker queue; also runs inline under `DEVPLACE_DISABLE_SERVICES=1`). The same `_run_correction` worker function runs in all cases.
- **The hooks (covers UI + REST + Devii + devRant in one place):** `content.create_content_item` (posts/projects/gists), `content.edit_content_item`, `content.create_comment_record`, `content.edit_comment_record`, `services/messaging/persist.persist_message` (DMs - sender is the user), `routers/profile/index.update_profile` (bio), and the two devRant direct-update edit paths (`routers/devrant/rants.edit_rant`, `routers/devrant/comments.edit_comment`). devRant create paths route through the shared content cores, so they are already hooked. Code-only and external paths (project files, Gitea) are deliberately not hooked.
- **The gateway call is fail-soft.** `correct_text(api_key, prompt, text)` is synchronous (runs on the background worker thread), POSTs to `INTERNAL_GATEWAY_URL` with `model=INTERNAL_MODEL` via `stealth.stealth_sync_client`, authenticated with the user's own `Bearer` api_key (per-user attribution). It returns the ORIGINAL text on any error, empty output, or suspiciously large output (`len > len(text) * MAX_GROWTH_FACTOR + 200`, rejecting hallucinated expansion). `_run_correction` reads the row back, corrects each registry field that has non-blank text, writes only changed fields via `table.update(updates, ["uid"])` without touching `updated_at` (auto-correction is not a user edit) or the slug (slugs are permanent), and `clear_user_cache(user_uid)` when `table == "users"` so the corrected bio re-caches.
- **The gateway call is fail-soft.** `correct_text(api_key, prompt, text)` is synchronous (runs on the background worker thread), POSTs to `INTERNAL_GATEWAY_URL` with `model=correction_model()` (`get_setting("correction_model", "") or INTERNAL_MODEL` - admin-configurable at `/admin/settings`, blank falls back to the gateway default `molodetz`; the gateway URL itself is never configurable per feature, always `INTERNAL_GATEWAY_URL`) via `stealth.stealth_sync_client`, authenticated with the user's own `Bearer` api_key (per-user attribution). It returns the ORIGINAL text on any error, empty output, or suspiciously large output (`len > len(text) * MAX_GROWTH_FACTOR + 200`, rejecting hallucinated expansion). `_run_correction` reads the row back, corrects each registry field that has non-blank text, writes only changed fields via `table.update(updates, ["uid"])` without touching `updated_at` (auto-correction is not a user edit) or the slug (slugs are permanent), and `clear_user_cache(user_uid)` when `table == "users"` so the corrected bio re-caches.
- **Per-user usage aggregation (only on success).** `correct_text` returns `(text, usage)`; `usage` is parsed from the gateway's `X-Gateway-*` response headers (`_usage_from_headers`) whenever the upstream call returned 200 (cost was incurred, even if the corrected output was rejected), else `None` on any failure. `_usage_from_headers` captures the token and cost headers PLUS the timing headers `X-Gateway-Upstream-Latency-Ms` and `X-Gateway-Total-Latency-Ms` (as `upstream_latency_ms`/`total_latency_ms`), so each call's timing is metered. `_run_correction` accumulates the per-field `usage` into one `totals` dict and, when `totals["calls"] > 0`, makes ONE call to `database.add_correction_usage(user_uid, totals)` (a single `totals` dict, not positional args) - so a 2-field content item is a single aggregated write, and a failed/empty correction records nothing. `add_correction_usage` (and `add_modifier_usage`) delegate to the shared `database._add_usage(usage_table, user_uid, totals)`: a single atomic `INSERT ... ON CONFLICT(user_uid) DO UPDATE SET col = col + excluded.col` upsert against the `correction_usage` table (per-user running SUMS: `calls`/`prompt_tokens`/`completion_tokens`/`total_tokens`/`cost_usd`/`upstream_latency_ms`/`total_latency_ms`/`updated_at`, unique index `idx_correction_usage_user`, the two latency columns REAL default 0.0, all ensured in `init_db`). It is a derived counter table (NOT in `SOFT_DELETE_TABLES`, like `gateway_usage_ledger`) and is deliberately separate from `users` so accumulating never invalidates the auth/user cache. `database.get_correction_usage(user_uid)` (via `_get_usage`) returns the stored sums PLUS computed averages: `avg_tokens` (total_tokens/calls), `avg_upstream_latency_ms`, `avg_total_latency_ms`, `avg_tokens_per_second` (completion_tokens over total upstream seconds), and `avg_cost_usd`.
- **Profile display:** `routers/profile/usage._correction_usage(uid, include_cost)` shapes it via the shared `_usage_view(data, include_cost)` (mirrors `_ai_quota`); `profile/index.py` builds it only for `is_owner or viewer_is_admin` and passes `include_cost=viewer_is_admin`, exposed as the `correction_usage` dict on the context and `ProfileOut`. The view surfaces the sums plus averages - `avg_tokens` (avg tokens/call), `avg_latency_ms` (avg upstream latency), `avg_total_latency_ms`, `avg_tokens_per_second` (avg speed), and `total_time_s` (total upstream seconds) - rendered as extra tiles on the card. **Financial gating:** tokens/call-count and the performance tiles show to the owner and admins; the dollar `cost_usd` and `avg_cost_usd` keys are present ONLY when `viewer_is_admin`, in BOTH the HTML card (`templates/profile.html`, `.correction-usage-*`) and the `respond(..., model=ProfileOut)` JSON (same rule as `_ai_quota`'s `spent_usd` - hiding it in the template alone would leak it to a member fetching their own profile as JSON). The card renders only when `correction_usage.calls` > 0.
- **Import-cycle discipline.** `correction.py` imports only `stealth`, `config`, `database.get_table`/`add_correction_usage`, and `services.background.background` at module top; `clear_user_cache` is imported lazily inside `_run_correction`. Never import `content` or `utils` at module top.
@@ -37,7 +37,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
**A sibling of AI content correction that runs only on an explicit inline directive.** The engine reuses the correction plumbing wholesale (`CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics, the per-user usage upsert) and differs only in the trigger and the apply-mode/enabled defaults: it is **enabled by default** and **synchronous by default**, and it runs ONLY where an authored prose field contains an inline `@ai <instruction>` directive.
- **The `@ai` gate is the whole difference.** `has_ai_directive(text)` matches the regex `@ai\s+\S` (case-insensitive). `schedule_modification(user, table, uid, request=None)` is a no-op unless a user is present, `table` is in `CORRECTABLE_FIELDS`, `user["ai_modifier_enabled"]` is truthy, the user has an `api_key`, AND at least one of the table's registry fields actually contains an `@ai` directive. `_run_modification` re-checks the gate per field, so untriggered fields are never sent to the gateway and never metered. Triggerless writes cost nothing. The configured prompt (default `config.DEFAULT_MODIFIER_PROMPT` = "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`") tells the model to execute the instruction and replace the marked part including the `@ai` marker.
- **Total reuse of the correction layer.** `CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics (`_run_inline_awaited` -> `loop.run_in_executor` + `request.scope[PENDING_SCOPE_KEY]` awaited by the `await_pending_corrections` middleware in `main.py`, the same self-deadlock-avoiding path), and the per-user usage upsert pattern are all imported from / mirror `services/correction.py`. `modify_text(api_key, prompt, text, context="")` composes a modifier system message and calls the shared `gateway_complete`. The same hooks fire it: `profile/index.update_profile` calls both `schedule_correction` and `schedule_modification`, and the content/comment/messaging cores invoke it alongside correction, so it covers the web UI, REST, devRant, and Devii in one place. Code and source files are never modified (same `CORRECTABLE_FIELDS` registry, `gists.source_code`/project files/Gitea excluded). `schedule_modification` is hooked alongside `schedule_correction` at the same content/comment/messaging/profile entrypoints.
- **Total reuse of the correction layer.** `CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics (`_run_inline_awaited` -> `loop.run_in_executor` + `request.scope[PENDING_SCOPE_KEY]` awaited by the `await_pending_corrections` middleware in `main.py`, the same self-deadlock-avoiding path), and the per-user usage upsert pattern are all imported from / mirror `services/correction.py`. `modify_text(api_key, prompt, text, context="")` composes a modifier system message and calls the shared `gateway_complete` with `model=modifier_model()` (`get_setting("modifier_model", "") or INTERNAL_MODEL`, its own admin-configurable setting at `/admin/settings`, independent of `correction_model` so each feature can use a different model - blank falls back to the gateway default `molodetz`). The same hooks fire it: `profile/index.update_profile` calls both `schedule_correction` and `schedule_modification`, and the content/comment/messaging cores invoke it alongside correction, so it covers the web UI, REST, devRant, and Devii in one place. Code and source files are never modified (same `CORRECTABLE_FIELDS` registry, `gists.source_code`/project files/Gitea excluded). `schedule_modification` is hooked alongside `schedule_correction` at the same content/comment/messaging/profile entrypoints.
- **Context-aware (modifier only, not correction).** Unlike correction, the modifier gives the model a grounding **context block** so an `@ai` instruction can reason about who is asking and what it is attached to. `services/ai_context.py` `build_context(table, uid, row, user_uid) -> str` assembles it; `_run_modification` builds it **lazily once per row** (only after a field is confirmed to contain `@ai`, so triggerless writes do no extra queries) and passes it to every field's `modify_text`, which appends it to the system message under a `# Context (use it to inform the result; never echo this block)` header. The block (fail-soft, length-capped, each part wrapped in try/except so a failed lookup never blocks the modification) has three parts:
1. **date** - `Today is DD/MM/YYYY on the DevPlace developer network.`
2. **author/stats** - the author's username, role, level, stars, post count (`get_user_post_count`), leaderboard rank (`get_user_rank`), follower count (`get_follow_counts`), member-since date, and bio (capped `MAX_BIO`).
+9 -3
View File
@@ -3,8 +3,8 @@
import logging
import re
from devplacepy.config import DEFAULT_MODIFIER_PROMPT
from devplacepy.database import add_modifier_usage, get_table
from devplacepy.config import DEFAULT_MODIFIER_PROMPT, INTERNAL_MODEL
from devplacepy.database import add_modifier_usage, get_setting, get_table
from devplacepy.services.ai_context import build_context
from devplacepy.services.background import background
from devplacepy.services.correction import (
@@ -24,6 +24,10 @@ def has_ai_directive(text: str | None) -> bool:
return bool(text) and AI_DIRECTIVE.search(text) is not None
def modifier_model() -> str:
return get_setting("modifier_model", "") or INTERNAL_MODEL
def modify_text(
api_key: str, prompt: str, text: str, context: str = ""
) -> tuple[str, dict | None]:
@@ -38,7 +42,9 @@ def modify_text(
"\n\n# Context (use it to inform the result; never echo this block)\n"
+ context
)
return gateway_complete(api_key, system, text, MODIFIER_TIMEOUT_SECONDS, None)
return gateway_complete(
api_key, system, text, MODIFIER_TIMEOUT_SECONDS, None, model=modifier_model()
)
def schedule_modification(
+13 -3
View File
@@ -13,7 +13,7 @@ from devplacepy.config import (
INTERNAL_GATEWAY_URL,
INTERNAL_MODEL,
)
from devplacepy.database import add_correction_usage, get_table
from devplacepy.database import add_correction_usage, get_setting, get_table
from devplacepy.services.background import background
from devplacepy.services.openai_gateway.usage import parse_usage_headers
@@ -79,12 +79,13 @@ def gateway_complete(
text: str,
timeout: float,
max_growth_factor: int | None = None,
model: str = INTERNAL_MODEL,
) -> tuple[str, dict | None]:
text = text or ""
if not text.strip():
return text, None
payload = {
"model": INTERNAL_MODEL,
"model": model or INTERNAL_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": text},
@@ -121,6 +122,10 @@ def gateway_complete(
return content, usage
def correction_model() -> str:
return get_setting("correction_model", "") or INTERNAL_MODEL
def correct_text(api_key: str, prompt: str, text: str) -> tuple[str, dict | None]:
system = (
"You are a text correction engine. Apply the correction instruction below to "
@@ -130,7 +135,12 @@ def correct_text(api_key: str, prompt: str, text: str) -> tuple[str, dict | None
+ (prompt or DEFAULT_CORRECTION_PROMPT).strip()
)
return gateway_complete(
api_key, system, text, CORRECTION_TIMEOUT_SECONDS, MAX_GROWTH_FACTOR
api_key,
system,
text,
CORRECTION_TIMEOUT_SECONDS,
MAX_GROWTH_FACTOR,
model=correction_model(),
)
+7 -1
View File
@@ -6,6 +6,9 @@ import logging
import re
from dataclasses import dataclass, field
from devplacepy.config import INTERNAL_MODEL
from devplacepy.database import get_setting
from .embeddings import embed_texts, local_embed
from .llm import complete_chat
from .store import Chunk, VectorStore
@@ -109,7 +112,10 @@ class DeepsearchChat:
)
try:
text = await complete_chat(
messages, self.api_key, max_tokens=CHAT_MAX_TOKENS
messages,
self.api_key,
model=get_setting("deepsearch_model", "") or INTERNAL_MODEL,
max_tokens=CHAT_MAX_TOKENS,
)
except Exception as exc:
logger.warning("deepsearch chat synthesis failed: %s", exc)
+3 -3
View File
@@ -68,7 +68,7 @@ The public **Tools -> SEO Diagnostics** auditor crawls a URL or sitemap with a h
- **Tables.** `seo_metadata` is polymorphic and soft-deletable (in `SOFT_DELETE_TABLES`, born-live `deleted_at`/`deleted_by`): `uid, target_type, target_uid, seo_title, seo_description, seo_keywords, status (ready|pending|failed), source (ai|plain), generated_at, created_at, updated_at`, keyed UNIQUE on `(target_type, target_uid)`. `init_db()` ensures every column, the UNIQUE `idx_seo_metadata_target` and the live `idx_seo_metadata_status (status, deleted_at)` index. `seo_usage` is a single-row config-like usage table (NOT soft-delete) mirroring `news_usage`, keyed `SEO_USAGE_KEY="seo_meta"`. Helpers in `database.py`: `get_seo_metadata`/`get_seo_metadata_batch`/`has_fresh_seo_metadata`/`upsert_seo_metadata`/`mark_seo_metadata_stale` (every read filters `deleted_at IS NULL`; `get_seo_metadata` returns only `status="ready"` live rows) and `add_seo_usage`/`get_seo_usage`.
- **Choke helper.** `services/seo_meta.py` `schedule_seo_meta(target_type, uid, regenerate=False)` and `schedule_seo_meta_for_table(table, uid, ...)` are import-cycle-free (only `database` + `queue`). They no-op for unknown types, missing uid, or (without `regenerate`) when a fresh `ready` row exists (`database.has_fresh_seo_metadata`); `regenerate=True` marks the row stale first (`database.mark_seo_metadata_stale`); both skip a target with an existing pending/running `seo_meta` job; otherwise `queue.enqueue("seo_meta", {target_type, target_uid}, "system", "seo_meta")`. Hooked at `content.create_content_item` (create, no-op guard) and `content.edit_content_item` (regenerate), the news publish sites in `services/news.py` (`status=="published"` only; existing-row update path uses `regenerate=True`), and `IssueCreateService` after the Gitea ticket is recorded. Because the work is async via the queue (NOT `run_in_executor`), the helper only enqueues.
- **`process`** loads the target row (posts/projects/gists/news via `get_table`, issues via `gitea.store.get_ticket`), builds grounding via `services/ai_context.build_context` (fail-soft for news/empty `user_uid`), and calls the gateway **off-thread** (`asyncio.to_thread(correction.gateway_complete, internal_gateway_key(), system, source_text, timeout)` - the synchronous gateway call posts to the in-process gateway on localhost, so it MUST run via `to_thread` or it self-deadlocks the single worker - the same lesson as `correction.py`'s sync mode). It demands strict JSON `{seo_title, seo_description, seo_keywords}`, parses fail-soft, re-clamps every field server-side via `seo_meta_text.clamp_generated`, and falls back to `seo_meta_text.plain_seo_defaults` (status `failed`, source `plain`) on any failure - **the fields are never empty**. Usage accumulates via `correction.new_usage_totals` and flushes once with `database.add_seo_usage(totals)` when `calls>0` (the single-row `seo_usage` table, mirroring `news_usage`). It emits an audit `seo.meta.generate`/`seo.meta.failed` (`record_system`, category `tools`) and `upsert_seo_metadata(...)`.
- **`process`** loads the target row (posts/projects/gists/news via `get_table`, issues via `gitea.store.get_ticket`), builds grounding via `services/ai_context.build_context` (fail-soft for news/empty `user_uid`), resolves the model via `self.get_config()["seo_meta_model"] or INTERNAL_MODEL` (`ConfigField`, group "SEO metadata", blank falls back to the gateway default `molodetz`), and calls the gateway **off-thread** (`asyncio.to_thread(self._generate, ..., model)` -> `correction.gateway_complete(internal_gateway_key(), system, source_text, timeout, model=model)` - the synchronous gateway call posts to the in-process gateway on localhost, so it MUST run via `to_thread` or it self-deadlocks the single worker - the same lesson as `correction.py`'s sync mode). It demands strict JSON `{seo_title, seo_description, seo_keywords}`, parses fail-soft, re-clamps every field server-side via `seo_meta_text.clamp_generated`, and falls back to `seo_meta_text.plain_seo_defaults` (status `failed`, source `plain`) on any failure - **the fields are never empty**. Usage accumulates via `correction.new_usage_totals` and flushes once with `database.add_seo_usage(totals)` when `calls>0` (the single-row `seo_usage` table, mirroring `news_usage`). It emits an audit `seo.meta.generate`/`seo.meta.failed` (`record_system`, category `tools`) and `upsert_seo_metadata(...)`.
- **Backfill.** `run_once` calls `super().run_once()` then a bounded backfill sweep (gated by `seo_meta_backfill_enabled`, `seo_meta_backfill_batch` per type per tick) over published content lacking a fresh `ready` row, so pre-existing items get metadata with no one-shot migration.
- **Admin surface.** `collect_metrics()` merges the `JobService` job-pipeline stats with `usage_metric_cards(get_seo_usage())`, so the **SEO Metadata** card on `/admin/services` shows both the live task pipeline and the AI cost/averages; the existing `admin.services.{name}` pub/sub topic + `live_view_relay` row pushes it live with NO new VIEWS row. A standard-paginated task list reads `queue.list_jobs(kind="seo_meta")` with `database.build_pagination` + `_pagination.html` when a dedicated page is desired.
- **Clamps (single source of truth, `seo_meta_text.py`):** `seo_title` hard cap 60 (word-boundary, single hyphen, keyword front-loaded); `seo_description` hard cap 160 (word-boundary via `seo.truncate`, key message in the first 120 chars); `seo_keywords` 5-8 distinct lowercase comma-joined terms (the `<meta keywords>` tag is dead for Google but the feature mandates it - emit a SHORT honest list, never stuffed). `plain_text_from_markdown` reuses `rendering._render_content` + `utils.strip_html` so markdown (and em-dash) never leaks.
@@ -81,7 +81,7 @@ The public **Tools -> DeepSearch** researcher is a multi-agent deep web research
- **Owner helper is shared:** `routers/tools/_shared.py` `owner_for(request)` returns `("user", uid)` or `("guest", X-Real-IP)`; both `seo.py` and `deepsearch.py` import it (do not re-inline the owner derivation).
- **Enqueue:** `POST /tools/deepsearch/run` (body `DeepsearchRunForm{query, depth 1-4, max_pages 1-30}`). It rejects with `429` if the owner already has a pending/running `deepsearch` job. It resolves the **logged-in user's `users.api_key`** (guests use `database.internal_gateway_key()`) into the job payload for per-user embedding/LLM spend attribution, generates the uid up front, writes a `deepsearch_sessions` row (`create_deepsearch_session`), enqueues the job carrying `{query, depth, max_pages, api_key, collection}`, and returns `{uid, status_url, ws_url}`. The enqueue uses a local `_enqueue` (not `queue.enqueue`) so the session uid and the job uid match.
- **`process`** writes `control.json` (state `running`) + `payload.json` (augmented with the cross-session `cached_hashes`) under `config.DEEPSEARCH_DIR/{uid}`, launches `python -m devplacepy.services.jobs.deepsearch.worker <payload_json> <output_dir>` via `create_subprocess_exec` (high `limit=`), pumps **NDJSON stdout frames** into the in-process `ProgressHub` (`progress.py`), and on completion loads `output_dir/report.json`, persists the URL cache (`upsert_deepsearch_url_cache`), and updates the session row. `cleanup()` **drops the ChromaDB collection** (`VectorStore.drop`) and removes the session dir. Disposable: the collection + report dir are both deleted, unlike Fork/isslop.
- **`process`** writes `control.json` (state `running`) + `payload.json` (augmented with the cross-session `cached_hashes` and `model = self.get_config()["deepsearch_model"] or INTERNAL_MODEL` - the admin-configurable `deepsearch_model` `ConfigField`, group "DeepSearch", blank falls back to the gateway default `molodetz`) under `config.DEEPSEARCH_DIR/{uid}`, launches `python -m devplacepy.services.jobs.deepsearch.worker <payload_json> <output_dir>` via `create_subprocess_exec` (high `limit=`), pumps **NDJSON stdout frames** into the in-process `ProgressHub` (`progress.py`), and on completion loads `output_dir/report.json`, persists the URL cache (`upsert_deepsearch_url_cache`), and updates the session row. `cleanup()` **drops the ChromaDB collection** (`VectorStore.drop`) and removes the session dir. Disposable: the collection + report dir are both deleted, unlike Fork/isslop. The same `deepsearch_model` setting also drives the grounded RAG chat (`services/deepsearch/chat.py` resolves it directly via `get_setting` at answer time, since chat turns are not job-payload-scoped).
- **Worker pipeline** (`worker.py`, stdlib + httpx + playwright, importable subprocess): `enhance.plan_queries` (gateway -> JSON sub-queries, deterministic fallback) -> `crawl.search_queries` (rsearch via standalone httpx, never `PlatformClient`; per-query result buckets are **round-robin interleaved** so every planned angle contributes pages, never just the first query) -> `crawl.crawl` (batches of `CRAWL_CONCURRENCY` concurrent httpx fetches then playwright render fallback, `guard_public_url` on the URL and every redirect, content-hash + URL-hash dedup; **`depth` follows in-page links**: after each level the links of every crawled page are scored by query-token overlap via `extract.relevant_links` and the top `LINKS_PER_PAGE` unseen ones form the next level, `depth=1` disables following) -> `chunking.chunk_text` -> `embeddings.embed_texts` (gateway, **local hashing fallback** when unavailable) -> `store.VectorStore.add` (Chroma) -> `orchestrate.orchestrate` (retrieval-grounded agents, see below). The worker writes `report.json` and `url_cache.json` and emits a `report_ready` frame carrying `synthesis`.
- **Search-provided content is a first-class source (`crawl.py`, the second junk-report fix).** `search_queries` calls rsearch with `content=true`, so each candidate carries the search engine's own readable `content`/`description` extract. This matters because the top sources for many questions are **bot-hostile** (X/Twitter, YouTube, Reddit, Facebook, Instagram, LinkedIn, TikTok - `HOSTILE_DOMAINS`): a headless fetch of those hits a login/consent wall ("Before you continue to YouTube", "Sign in to X") and yields near-zero text, which is why a 12-source run used to collapse to ~14 chunks. Now `crawl._resolve_candidate` **skips the fetch entirely for a hostile domain and uses the rsearch snippet** (`_snippet_page`, `source="search"`, `SNIPPET_MIN_CHARS` floor), and for every other domain it fetches normally but keeps the rsearch snippet as a **floor** (uses whichever of crawl-text vs snippet is longer), so a walled or thin page still contributes its real content instead of being dropped. This alone took a query from "cannot be answered" to a correct cited answer (14 -> 61 chunks, diversity 0.333 -> 0.75). **Never revert `content=true` and never send a headless render at a `HOSTILE_DOMAINS` host.**
- **Content extraction (`extract.py`, stdlib only):** `extract_html(raw, base_url)` is a readability-grade `HTMLParser` extractor used by both the httpx and playwright fetch paths (the old naive regex tag-stripper produced nav/cookie-banner boilerplate as "content" - the historic root cause of junk reports). It skips `script/style/nav/header/footer/aside/form` and ARIA `role=navigation|banner|contentinfo|...` regions, prefers `<article>`/`<main>` when they carry at least `MIN_CONTENT_TOTAL` chars, drops link-dense blocks (`MAX_LINK_DENSITY`, menus) and sub-`MIN_BLOCK_CHARS` fragments, unescapes entities, and emits real paragraphs joined by blank lines - which also makes `chunking.chunk_text`'s paragraph split actually fire (the flattened text used to be sliced mid-sentence). It also returns the page's `(url, anchor_text)` links (absolute, deduped, nav links excluded) for depth crawling; `relevant_links(links, query, limit)` scores them by query-token overlap and filters non-document extensions.
@@ -109,7 +109,7 @@ The public **Tools -> AI Usage Analyzer** classifies a git repository or website
- **Engine layout:** `services/jobs/isslop/` holds `acquisition/` (git probe via `git ls-remote`, depth-1 clone with size preflight + live 3 GB kill guard, stealth Playwright website crawler with HTTP fallback, path-traversal-safe workspace helpers), `analysis/` (exclusion rules, stylometric metrics, language detection, per-repo baselines, `signals/` with one detector family per file, two-axis scoring), `agent/` (gateway LLM client, per-file classifier, vision reviewer, report writer with deterministic fallback), plus `pipeline.py` (the event-yielding run), `worker.py` (subprocess entry), `events.py` (frame protocol), `persistence.py` (`EventPersister` writes events/file results/image results/report and stamps the analysis row), `store.py` (all DB access), `badge.py` (SVG), `service.py` (`IsslopService`), `config.py` (all constants + `WorkerSettings`).
- **Worker contract:** `IsslopService.process` writes the worker payload (url + admin toggles + gateway endpoint/model/key) to `config.ISSLOP_RUNS_DIR/{uid}/payload.json`, resolves the workspace under `config.ISSLOP_WORKSPACES_DIR` (`workspace_for` rejects any path escaping the root), launches `python -m devplacepy.services.jobs.isslop.worker <payload> <workspace>`, and relays each NDJSON stdout line through `EventPersister.apply` (SQLite) then `pubsub.publish`. The workspace and run dir are removed in a `finally`; the pipeline also deletes the workspace itself as its final act, so **no acquired source survives an analysis** - only the report and its evidence rows.
- **AI through the gateway only:** `agent/llm.py` talks solely to `config.INTERNAL_GATEWAY_URL` with model `molodetz` and `database.internal_gateway_key()` (vision uses the same model - the gateway handles image parts). `review_available`/`vision_available` gate the AI and image stages; on any gateway failure the static engine remains authoritative and the report falls back to the deterministic composer. All HTTP (gateway, git size preflight, website fallback crawl) goes through `stealth_async_client`.
- **AI through the gateway only:** `agent/llm.py` talks solely to `config.INTERNAL_GATEWAY_URL` and `database.internal_gateway_key()` (vision uses the same model - the gateway handles image parts). The model name is admin-configurable (`isslop_ai_model` `ConfigField`, group "Analysis", blank falls back to `INTERNAL_MODEL`/`molodetz`, resolved once in `IsslopService._worker_payload` and carried to the worker via the payload, never touching which gateway is used). `review_available`/`vision_available` gate the AI and image stages; on any gateway failure the static engine remains authoritative and the report falls back to the deterministic composer. All HTTP (gateway, git size preflight, website fallback crawl) goes through `stealth_async_client`.
- **Artifacts are permanent, the job row is not.** Like `ForkService`, `cleanup()` never touches `isslop_analyses`/`isslop_events`/`isslop_file_results`/`isslop_image_results`/`isslop_reports` - the report and badge are public capability URLs meant to outlive the run; the retention sweep removes only the `jobs` tracking row. `devplace isslop clear` is the only bulk hard-delete (plus per-analysis `store.purge_analysis`).
- **Ownership and guest history sync:** the owner is `("user", uid)` or `("guest", DEVII_GUEST_COOKIE)` - NOT the tools `_shared.owner_for` IP fallback, because history must survive IP changes and be claimable. The page/list/run handlers mint the guest cookie when absent (same cookie as Devii/customization, one guest identity platform-wide). `_sync_guest_history` runs on page and list requests: when a signed-in user still carries a guest cookie, `store.claim_guest_analyses` re-owns those rows via UPDATE (a move, never a copy - no duplicate data). One active analysis per owner (`429` otherwise, audited `denied`).
- **Tables:** `isslop_analyses` is soft-deletable (in `SOFT_DELETE_TABLES`, born-live inserts, reads filter `deleted_at IS NULL`, indexed on `(owner_kind, owner_id, created_at)`/`status`/`content_hash`); the evidence tables (`isslop_events` keyed `(analysis_uid, seq)`, `isslop_file_results`, `isslop_image_results`, `isslop_reports` UNIQUE on `analysis_uid`) are GC-only evidence purged with their analysis. All ensured in `init_db()`.
@@ -70,7 +70,10 @@ def _noop(frame: dict) -> None:
async def plan_queries(
query: str, api_key: str, emit: Callable[[dict], None] = _noop
query: str,
api_key: str,
emit: Callable[[dict], None] = _noop,
model: str = INTERNAL_MODEL,
) -> list[str]:
emit(
{
@@ -80,7 +83,7 @@ async def plan_queries(
}
)
payload = {
"model": INTERNAL_MODEL,
"model": model or INTERNAL_MODEL,
"messages": [
{"role": "system", "content": PLANNER_PROMPT},
{"role": "user", "content": query},
@@ -132,12 +135,13 @@ async def plan_followup_queries(
covered_titles: list[str],
api_key: str,
emit: Callable[[dict], None] = _noop,
model: str = INTERNAL_MODEL,
) -> list[str]:
if not covered_titles:
return []
titles_block = "\n".join(f"- {title}" for title in covered_titles[:MAX_COVERED_TITLES])
payload = {
"model": INTERNAL_MODEL,
"model": model or INTERNAL_MODEL,
"messages": [
{"role": "system", "content": FOLLOWUP_PROMPT},
{
@@ -12,6 +12,7 @@ from dataclasses import dataclass, field
from itertools import zip_longest
from urllib.parse import urlparse
from devplacepy.config import INTERNAL_MODEL
from devplacepy.services.deepsearch.embeddings import embed_texts, local_embed
from devplacepy.services.deepsearch.llm import request_completion
@@ -205,11 +206,12 @@ def _page_context(pages: list) -> str:
async def _complete(
messages: list[dict], api_key: str, max_tokens: int
messages: list[dict], api_key: str, max_tokens: int, model: str = INTERNAL_MODEL
) -> tuple[str, dict]:
data, raw_usage, elapsed_ms = await request_completion(
messages,
api_key,
model=model or INTERNAL_MODEL,
max_tokens=max_tokens,
timeout=AGENT_TIMEOUT_SECONDS,
)
@@ -354,21 +356,23 @@ def _agent_done(emit: Callable[[dict], None], agent: str, usage: dict) -> None:
)
async def _write_report(question: str, context: str, api_key: str) -> tuple[str, dict]:
async def _write_report(
question: str, context: str, api_key: str, model: str = INTERNAL_MODEL
) -> tuple[str, dict]:
messages = [
{"role": "system", "content": REPORT_PROMPT},
{"role": "user", "content": f"QUESTION: {question}\n\nSOURCES:\n{context}"},
]
text, usage = await _complete(messages, api_key, REPORT_MAX_TOKENS)
text, usage = await _complete(messages, api_key, REPORT_MAX_TOKENS, model)
summary = text.strip()
if not summary:
text, usage = await _complete(messages, api_key, REPORT_MAX_TOKENS)
text, usage = await _complete(messages, api_key, REPORT_MAX_TOKENS, model)
summary = text.strip()
return summary, usage
async def _extract_findings(
question: str, summary: str, context: str, api_key: str
question: str, summary: str, context: str, api_key: str, model: str = INTERNAL_MODEL
) -> tuple[list[dict], dict]:
messages = [
{"role": "system", "content": FINDINGS_PROMPT},
@@ -382,7 +386,7 @@ async def _extract_findings(
]
usage: dict = {}
for _attempt in range(2):
text, usage = await _complete(messages, api_key, FINDINGS_MAX_TOKENS)
text, usage = await _complete(messages, api_key, FINDINGS_MAX_TOKENS, model)
findings = [
f
for f in (_parse_json(text).get("findings") or [])
@@ -393,7 +397,9 @@ async def _extract_findings(
return [], usage
async def _suggest_followups(question: str, summary: str, api_key: str) -> list[str]:
async def _suggest_followups(
question: str, summary: str, api_key: str, model: str = INTERNAL_MODEL
) -> list[str]:
try:
text, _usage = await _complete(
[
@@ -405,6 +411,7 @@ async def _suggest_followups(question: str, summary: str, api_key: str) -> list[
],
api_key,
FOLLOWUP_QUESTIONS_MAX_TOKENS,
model,
)
questions = _parse_json(text).get("questions")
if not isinstance(questions, list):
@@ -423,6 +430,7 @@ async def orchestrate(
emit: Callable[[dict], None],
store=None,
queries: list[str] | None = None,
model: str = INTERNAL_MODEL,
) -> Orchestration:
diversity = source_diversity(pages)
if not pages:
@@ -455,13 +463,15 @@ async def orchestrate(
)
try:
_run_agent(emit, "summarizer", "Writing the research report")
summary, summary_usage = await _write_report(question, context, api_key)
summary, summary_usage = await _write_report(question, context, api_key, model)
_agent_done(emit, "summarizer", summary_usage)
if not summary:
return _heuristic(question, pages, reason="empty report from the model", emit=emit)
_run_agent(emit, "extractor", "Extracting key findings")
findings, findings_usage = await _extract_findings(question, summary, context, api_key)
findings, findings_usage = await _extract_findings(
question, summary, context, api_key, model
)
_agent_done(emit, "extractor", findings_usage)
source_digest = _numbered_source_digest(pages)
@@ -480,6 +490,7 @@ async def orchestrate(
],
api_key,
LINKER_MAX_TOKENS,
model,
)
_agent_done(emit, "linker", linker_usage)
confidence = float(_parse_json(link_raw).get("confidence", 0.0))
@@ -495,7 +506,7 @@ async def orchestrate(
score = int(
min(SCORE_MAX, (confidence * 0.5 + diversity * 0.3 + coverage * 0.2) * SCORE_MAX)
)
follow_up_questions = await _suggest_followups(question, summary, api_key)
follow_up_questions = await _suggest_followups(question, summary, api_key, model)
return Orchestration(
summary=summary,
findings=findings,
+13 -1
View File
@@ -9,7 +9,8 @@ import shutil
import sys
from pathlib import Path
from devplacepy.config import BASE_DIR, DEEPSEARCH_DIR
from devplacepy.config import BASE_DIR, DEEPSEARCH_DIR, INTERNAL_MODEL
from devplacepy.services.base import ConfigField
from devplacepy.services.deepsearch.store import VectorStore
from devplacepy.services.jobs.base import JobService
@@ -33,6 +34,16 @@ class DeepsearchService(JobService):
def __init__(self):
super().__init__(name="deepsearch", interval_seconds=2)
self.config_fields = list(self.config_fields) + [
ConfigField(
"deepsearch_model",
"AI model",
type="str",
default="",
help="Model sent to the internal AI gateway for query planning and report synthesis. Blank uses the gateway default model.",
group="DeepSearch",
),
]
def session_dir(self, uid: str) -> Path:
return DEEPSEARCH_DIR / uid
@@ -53,6 +64,7 @@ class DeepsearchService(JobService):
json.dumps({"state": "running"}), encoding="utf-8"
)
payload["collection"] = self.collection_name(uid)
payload["model"] = self.get_config()["deepsearch_model"] or INTERNAL_MODEL
payload["cached_hashes"] = self._cached_hashes(database)
payload_path = output_dir / "payload.json"
payload_path.write_text(json.dumps(payload), encoding="utf-8")
@@ -8,6 +8,7 @@ import sys
from datetime import datetime, timezone
from pathlib import Path
from devplacepy.config import INTERNAL_MODEL
from devplacepy.services.deepsearch.embeddings import embed_texts, local_embed
from devplacepy.services.deepsearch.store import Chunk, VectorStore
from devplacepy.utils import generate_uid
@@ -162,12 +163,13 @@ async def _run(payload: dict, output_dir: Path) -> dict:
max_pages = int(payload.get("max_pages", 12))
depth = int(payload.get("depth", 2))
api_key = payload.get("api_key", "")
model = payload.get("model") or INTERNAL_MODEL
collection = payload.get("collection", "")
cached_hashes = set(payload.get("cached_hashes", []))
should_stop = _make_stop(output_dir)
_stage("planning", "Planning research queries", PHASE_PLANNING)
queries = await plan_queries(query, api_key, _emit)
queries = await plan_queries(query, api_key, _emit, model=model)
_emit({"type": "queries", "queries": queries})
_stage("searching", "Searching the web", PHASE_SEARCHING)
@@ -192,7 +194,9 @@ async def _run(payload: dict, output_dir: Path) -> dict:
and not await should_stop()
):
covered_titles = [page.title for page in outcome.pages if page.title]
followups = await plan_followup_queries(query, covered_titles, api_key, _emit)
followups = await plan_followup_queries(
query, covered_titles, api_key, _emit, model=model
)
if not followups:
break
round_no += 1
@@ -244,7 +248,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
_stage("analysis", "Running research agents", PHASE_ANALYSIS)
result = await orchestrate(
query, outcome.pages, api_key, _emit, store=store, queries=queries
query, outcome.pages, api_key, _emit, store=store, queries=queries, model=model
)
_stage("synthesis", "Compiling cited report", PHASE_SYNTHESIS)
+11 -3
View File
@@ -8,14 +8,14 @@ import shutil
import sys
from pathlib import Path
from devplacepy.config import BASE_DIR, ISSLOP_RUNS_DIR, ISSLOP_WORKSPACES_DIR
from devplacepy.config import BASE_DIR, INTERNAL_MODEL, ISSLOP_RUNS_DIR, ISSLOP_WORKSPACES_DIR
from devplacepy.database import INTERNAL_GATEWAY_URL, get_int_setting, get_setting, internal_gateway_key
from devplacepy.services import pubsub
from devplacepy.services.base import ConfigField
from devplacepy.services.jobs.base import JobService
from devplacepy.services.jobs.isslop import store
from devplacepy.services.jobs.isslop.acquisition.workspace import remove_workspace, workspace_for
from devplacepy.services.jobs.isslop.config import IMAGE_MAX_COUNT, LLM_MODEL, WORKER_TIMEOUT_SECONDS
from devplacepy.services.jobs.isslop.config import IMAGE_MAX_COUNT, WORKER_TIMEOUT_SECONDS
from devplacepy.services.jobs.isslop.events import KIND_DONE, KIND_ERROR, WorkerEvent
from devplacepy.services.jobs.isslop.persistence import EventPersister
@@ -88,6 +88,14 @@ class IsslopService(JobService):
help="Deterministically sampled cap on images sent to the vision model.",
group="Analysis",
),
ConfigField(
"isslop_ai_model",
"AI model",
type="str",
default="",
help="Model sent to the internal AI gateway for the file review and image review passes. Blank uses the gateway default model.",
group="Analysis",
),
]
def run_dir(self, uid: str) -> Path:
@@ -96,7 +104,7 @@ class IsslopService(JobService):
def _worker_payload(self, job: dict) -> dict:
payload = dict(job.get("payload", {}))
payload["llm_endpoint"] = INTERNAL_GATEWAY_URL
payload["llm_model"] = LLM_MODEL
payload["llm_model"] = get_setting("isslop_ai_model", "") or INTERNAL_MODEL
payload["api_key"] = internal_gateway_key()
payload["allow_private"] = get_setting("isslop_allow_private", "0") == "1"
payload["ai_review"] = get_setting("isslop_ai_review", "1") == "1"
+13 -1
View File
@@ -4,6 +4,7 @@ import asyncio
import json
import logging
from devplacepy.config import INTERNAL_MODEL
from devplacepy.database import (
add_seo_usage,
get_seo_usage,
@@ -81,6 +82,14 @@ class SeoMetaService(JobService):
help="How many published items to enqueue for backfill each tick.",
group="SEO metadata",
),
ConfigField(
"seo_meta_model",
"AI model",
type="str",
default="",
help="Model sent to the internal AI gateway for metadata generation. Blank uses the gateway default model.",
group="SEO metadata",
),
]
async def process(self, job: dict) -> dict:
@@ -106,8 +115,9 @@ class SeoMetaService(JobService):
return {"target_type": target_type, "target_uid": target_uid, "source": "plain"}
totals = new_usage_totals()
model = self.get_config()["seo_meta_model"] or INTERNAL_MODEL
result = await asyncio.to_thread(
self._generate, target_type, target_uid, row, title, body, defaults, totals
self._generate, target_type, target_uid, row, title, body, defaults, totals, model
)
if totals["calls"]:
add_seo_usage(totals)
@@ -144,6 +154,7 @@ class SeoMetaService(JobService):
body: str,
defaults: dict,
totals: dict,
model: str,
) -> dict:
context = ""
try:
@@ -161,6 +172,7 @@ class SeoMetaService(JobService):
SYSTEM_PROMPT,
source_text,
GENERATION_TIMEOUT_SECONDS,
model=model,
)
if usage:
for key in totals:
+7 -3
View File
@@ -4,7 +4,7 @@ This file documents the automated developer-news import pipeline. Claude Code au
## Overview
`BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation. `ServiceManager` is a singleton that registers, starts, and stops services. `NewsService` (`services/news/service.py`, `default_enabled=True`, registered unconditionally in `main.py`) is a fully automatic, zero-maintenance import pipeline: it fetches articles from `news_api_url`, **cleans** each (HTML strip + `clean_news_text`/`JUNK_PATTERNS` Reddit-boilerplate removal), **fetches and perceptually compares the images** (SSRF-guarded fetch + Pillow decode + `imagehash` phash off-thread; placeholder = too small / undecodable / a phash shared across 2+ different articles), **grades deterministically** (AI grade on cleaned text via `news_ai_url` + a reliability gate + a unique-image bonus and thin-content penalty -> effective `grade`, raw in `ai_grade`), **reformats every article that clears the publish threshold into clean Markdown** (`_format_article` + the editable `news_format_prompt`; paragraphs, `## ` headings, lists and code, preserving every fact; fail-soft to the cleaned original, toggle `news_format_enabled`; draft-bound articles - valid but below `news_grade_threshold` - are never sent for formatting, saving the call entirely), and inserts ALL of them into `news` with `status="published"`/`"draft"` on `news_grade_threshold` - nothing is silently skipped. After the loop it **auto-rotates Featured + Landing** (`_apply_landing_selection`, top scored unique-image articles in the recent window) while honouring per-row `featured_locked`/`landing_locked` set when an admin manually toggles. **AI usage is metered like the correction/modifier consumers**: each gateway call's `X-Gateway-*` response headers are parsed (`parse_usage_headers`) and the run's totals accumulated into the durable single-row `news_usage` table (`database.add_news_usage`/`get_news_usage`, same SUMs-plus-computed-averages shape as `correction_usage`); `NewsService.collect_metrics()` surfaces calls/tokens/cost and the per-call averages as `stats` on the admin-only `/admin/services` page. The full ruleset is on `NewsService.description` and surfaces on `/admin/services`, which also polls live status and the log tail.
`BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation. `ServiceManager` is a singleton that registers, starts, and stops services. `NewsService` (`services/news/service.py`, `default_enabled=True`, registered unconditionally in `main.py`) is a fully automatic, zero-maintenance import pipeline: it fetches articles from `news_api_url`, **cleans** each (HTML strip + `clean_news_text`/`JUNK_PATTERNS` Reddit-boilerplate removal), **fetches and perceptually compares the images** (SSRF-guarded fetch + Pillow decode + `imagehash` phash off-thread; placeholder = too small / undecodable / a phash shared across 2+ different articles), **grades deterministically** (AI grade on cleaned text via `news_ai_url` + a reliability gate + a unique-image bonus and thin-content penalty -> effective `grade`, raw in `ai_grade`), **reformats every article that clears the publish threshold into clean Markdown** (`_format_article` + the editable `news_format_prompt`, its OWN `news_format_url`/`news_format_model`/`news_format_key` - independent of grading, see "AI reformatting" below; paragraphs, `## ` headings, lists and code, preserving every fact; fail-soft to the cleaned original, toggle `news_format_enabled`; draft-bound articles - valid but below `news_grade_threshold` - are never sent for formatting, saving the call entirely), and inserts ALL of them into `news` with `status="published"`/`"draft"` on `news_grade_threshold` - nothing is silently skipped. After the loop it **auto-rotates Featured + Landing** (`_apply_landing_selection`, top scored unique-image articles in the recent window) while honouring per-row `featured_locked`/`landing_locked` set when an admin manually toggles. **AI usage is metered like the correction/modifier consumers**: each gateway call's `X-Gateway-*` response headers are parsed (`parse_usage_headers`) and the run's totals accumulated into the durable single-row `news_usage` table (`database.add_news_usage`/`get_news_usage`, same SUMs-plus-computed-averages shape as `correction_usage`); `NewsService.collect_metrics()` surfaces calls/tokens/cost and the per-call averages as `stats` on the admin-only `/admin/services` page. The full ruleset is on `NewsService.description` and surfaces on `/admin/services`, which also polls live status and the log tail.
## Per-run flow
@@ -13,11 +13,15 @@ The per-run flow per article is: clean text -> fetch and perceptually compare im
- Fetches `GET {news_api_url}` -> `{"articles": [...]}`; already-synced `external_id`s (from `news_sync`) are skipped.
- Every article is graded via AI on the CLEANED text: `POST {news_ai_url}` with model `{news_ai_model}`, temperature 0. Both default to the free, local **aquality** quality model (`AQUALITY_NEWS_GRADING_URL` = `https://aquality.cloud.pravda.education/v1/chat/completions` / `AQUALITY_NEWS_GRADING_MODEL` = `"aquality"`, `devplacepy/config.py`) - a deterministic scikit-learn regressor trained on this site's own editorial history (`grade`/`status` columns), served behind an OpenAI-chat-completions-compatible shim so no code here changes to call it. No API key is required (the key still falls back to `internal_gateway_key()` when `news_ai_key`/`NEWS_AI_KEY` is unset, but aquality ignores it unless its own `NEWS_QUALITY_API_KEY` is configured). `migrate_ai_gateway_settings()` (`database/schema.py`) one-time-migrates any existing `news_ai_url`/`news_ai_model` still on the old internal-gateway defaults over to aquality, without touching a value an admin has customized. Point `news_ai_url` at a real generative chat model instead to go back to LLM-based grading (higher quality, billed).
- ALL articles are inserted into `news` regardless of grade (never silently skipped). Articles re-synced each run (upsert by `external_id`); grade, status, image, and images updated each cycle. Slugs via `make_combined_slug(title, uid)`.
- All parameters (`news_api_url`, `news_ai_url`, `news_ai_model`, `news_grade_threshold`, `news_ai_key`, `news_format_enabled`, `news_format_prompt`, interval) are declared as `config_fields` and edited on the Services tab. The full grading ruleset is carried on `NewsService.description` (the `GRADING_RULES_DESCRIPTION` module string) and renders on `/admin/services`.
- All parameters (`news_api_url`, `news_ai_url`, `news_ai_model`, `news_grade_threshold`, `news_ai_key`, `news_format_enabled`, `news_format_url`, `news_format_model`, `news_format_key`, `news_format_prompt`, interval) are declared as `config_fields` and edited on the Services tab. The full grading ruleset is carried on `NewsService.description` (the `GRADING_RULES_DESCRIPTION` module string) and renders on `/admin/services`.
## AI reformatting (`_format_article`, `FORMAT_PROMPT_SPEC`)
After grading, every PUBLISHED article (one that passed the reliability gate, got a grade, and whose `effective_score` reaches `news_grade_threshold` - `published` in `run_once`, not merely `result.valid`) has its body reformatted by the AI into clean Markdown - short paragraphs, `## ` section headings, bullet/numbered lists, and inline/fenced code - turning the source wall of text into a readable article. It reuses the grading endpoint/model/key (`news_ai_url`/`news_ai_model`/`_get_ai_key`) at `temperature 0.3`, `FORMAT_MAX_TOKENS=6000`, with the cleaned `description`+`content` (capped at `FORMAT_INPUT_MAX_CHARS=14000`) appended to the editable `news_format_prompt`. The prompt forbids inventing/removing facts and only restructures. The result is fence-stripped (`_strip_md_fence`), validated to be at least `MIN_BODY_CHARS`, capped at `FORMAT_OUTPUT_MAX_CHARS=30000`, and stored as the `news.content` (rendered server-side by `render_content`, the markdown engine, on `news_detail.html`). It is fail-soft: on any error, an empty/too-short result, or `news_format_enabled` off, the cleaned original content is stored unchanged. Toggle with the `news_format_enabled` bool config field. **Defaults to `False`** since aquality, the default `news_ai_url` grading model, is a scoring-only classifier/regressor with no text-generation capability - it answers this call's prompt (no `Description:`/`Content:` labels, so the shared `GRADE_PROMPT_PATTERN` regex on the aquality side does not match) with an empty reply, which this fail-soft path already treats as "keep the cleaned original." Enable it only after pointing `news_ai_url` at a real generative chat model.
After grading, every PUBLISHED article (one that passed the reliability gate, got a grade, and whose `effective_score` reaches `news_grade_threshold` - `published` in `run_once`, not merely `result.valid`) has its body reformatted by the AI into clean Markdown - short paragraphs, `## ` section headings, bullet/numbered lists, and inline/fenced code - turning the source wall of text into a readable article.
**Formatting has its OWN endpoint/model/key, entirely independent of grading (load-bearing - do not merge them back together).** `news_format_url`/`news_format_model` (group "AI formatting") default to `INTERNAL_GATEWAY_URL`/`INTERNAL_MODEL` (`FORMAT_URL_DEFAULT`/`FORMAT_MODEL_DEFAULT`, `constants.py`) - a real generative model out of the box - which is why `news_format_enabled` can default to `True`-capable behavior the moment it is turned on, with zero extra configuration. This is deliberate: the default GRADING endpoint (`news_ai_url`) is aquality, a scoring-only classifier/regressor with no text-generation capability, so reusing it for formatting would silently no-op (aquality answers the formatting prompt - no `Description:`/`Content:` labels, so its `GRADE_PROMPT_PATTERN` regex does not match - with an empty reply, which the fail-soft path already treats as "keep the cleaned original", masking the misconfiguration as a no-op rather than a visible error). Splitting the two AI calls into independent config groups means an admin never has to choose between free grading and generative formatting. `news_format_key` (secret) resolves via `_get_ai_key("news_format_key", fallback_setting="news_ai_key")` - blank falls back to `news_ai_key`, then the gateway's internal key - so a shared custom key still works with zero extra setup, but a different provider per call is fully supported.
The call runs at `temperature 0.3`, `FORMAT_MAX_TOKENS=6000`, with the cleaned `description`+`content` (capped at `FORMAT_INPUT_MAX_CHARS=14000`) appended to the editable `news_format_prompt`. The prompt forbids inventing/removing facts and only restructures. The result is fence-stripped (`_strip_md_fence`), validated to be at least `MIN_BODY_CHARS`, capped at `FORMAT_OUTPUT_MAX_CHARS=30000`, and stored as the `news.content` (rendered server-side by `render_content`, the markdown engine, on `news_detail.html`). It is fail-soft: on any error, an empty/too-short result, or `news_format_enabled` off, the cleaned original content is stored unchanged. Toggle with the `news_format_enabled` bool config field (still defaults to `False` - grading and formatting are billed independently, and reformatting every article is an admin opt-in, not because it depends on `news_ai_url` any more).
## AI usage metering and stats (the shared `usage.py` helpers, `news_usage`, `collect_metrics`)
+8 -2
View File
@@ -8,13 +8,17 @@ from devplacepy.database import get_setting, internal_gateway_key
logger = logging.getLogger(__name__)
def _get_ai_key() -> str:
def _get_ai_key(setting: str = "news_ai_key", fallback_setting: str | None = None) -> str:
key = os.environ.get("NEWS_AI_KEY")
if key:
return key
key = get_setting("news_ai_key", "")
key = get_setting(setting, "")
if key:
return key
if fallback_setting:
key = get_setting(fallback_setting, "")
if key:
return key
return internal_gateway_key()
@@ -24,8 +28,10 @@ from .constants import (
FEATURE_MIN_SCORE,
FORMAT_INPUT_MAX_CHARS,
FORMAT_MAX_TOKENS,
FORMAT_MODEL_DEFAULT,
FORMAT_OUTPUT_MAX_CHARS,
FORMAT_PROMPT_SPEC,
FORMAT_URL_DEFAULT,
GRADE_MAX_TOKENS,
GRADE_PROMPT_SPEC,
GRADE_THRESHOLD_DEFAULT,
+2
View File
@@ -14,6 +14,8 @@ AQUALITY_URL_DEFAULT = AQUALITY_NEWS_GRADING_URL
AQUALITY_MODEL_DEFAULT = AQUALITY_NEWS_GRADING_MODEL
AI_URL_DEFAULT = AQUALITY_URL_DEFAULT
AI_MODEL_DEFAULT = AQUALITY_MODEL_DEFAULT
FORMAT_URL_DEFAULT = INTERNAL_GATEWAY_URL
FORMAT_MODEL_DEFAULT = INTERNAL_MODEL
GRADE_THRESHOLD_DEFAULT = 7
GRADE_MAX_TOKENS = 2000
FORMAT_MAX_TOKENS = 6000
+41 -6
View File
@@ -28,8 +28,10 @@ from .constants import (
FEATURE_MIN_SCORE,
FORMAT_INPUT_MAX_CHARS,
FORMAT_MAX_TOKENS,
FORMAT_MODEL_DEFAULT,
FORMAT_OUTPUT_MAX_CHARS,
FORMAT_PROMPT_SPEC,
FORMAT_URL_DEFAULT,
GRADE_MAX_TOKENS,
GRADE_PROMPT_SPEC,
GRADE_THRESHOLD_DEFAULT,
@@ -138,13 +140,44 @@ class NewsService(BaseService):
default=False,
help=(
"When enabled, every valid article is reformatted into clean "
"Markdown (paragraphs, headings, lists) after grading. The "
"default grading endpoint (aquality) is a scoring-only model "
"and cannot reformat text, so this defaults off; enable it "
"only when news_ai_url points at a generative chat model."
"Markdown (paragraphs, headings, lists) after grading. This uses "
"its own AI formatting endpoint/model below, independent of AI "
"grading, so it defaults to the internal gateway's generative "
"model and works even while grading stays on the free, "
"scoring-only aquality model."
),
group="AI formatting",
),
ConfigField(
"news_format_url",
"AI formatting URL",
type="url",
default=FORMAT_URL_DEFAULT,
help=(
"Chat-completions endpoint used to reformat each published "
"article into Markdown. Defaults to the internal AI gateway, "
"which is a real generative model (unlike the default grading "
"endpoint)."
),
group="AI formatting",
),
ConfigField(
"news_format_model",
"AI formatting model",
type="str",
default=FORMAT_MODEL_DEFAULT,
help="Model name sent to the formatting endpoint. Independent of the AI grading model.",
group="AI formatting",
),
ConfigField(
"news_format_key",
"AI formatting API key",
type="password",
default="",
secret=True,
help="Defaults to news_ai_key, then the gateway's internal key.",
group="AI formatting",
),
ConfigField(
"news_format_prompt",
"Formatting prompt specification",
@@ -170,6 +203,8 @@ class NewsService(BaseService):
ai_model = config["news_ai_model"]
threshold = config["news_grade_threshold"]
format_enabled = config["news_format_enabled"]
format_url = config["news_format_url"]
format_model = config["news_format_model"]
self.log(f"Fetching news from {api_url}")
async with stealth.stealth_async_client(timeout=30.0) as client:
@@ -261,7 +296,7 @@ class NewsService(BaseService):
formatted_content = ""
if published and format_enabled:
formatted_content = await self._format_article(
article, ai_url, ai_model, client, usage_totals
article, format_url, format_model, client, usage_totals
)
saved_new = self._store_article(
@@ -655,7 +690,7 @@ class NewsService(BaseService):
}
headers = {"Content-Type": "application/json", "X-App-Reference": "devplace-news-v-1-0-0"}
ai_key = _get_ai_key()
ai_key = _get_ai_key("news_format_key", fallback_setting="news_ai_key")
if ai_key:
headers["Authorization"] = f"Bearer {ai_key}"
+6
View File
@@ -131,6 +131,12 @@ until the httpx timeout. Same trap as `correction.py` sync mode and `SeoMetaServ
**Attribution:** the bearer token is the answering member's own `users.api_key`, so the spend lands
on that member in the existing gateway ledger. No new usage table.
**Model is admin-configurable.** `grading.grading_model()` (`get_setting("quiz_grading_model", "") or
INTERNAL_MODEL`) resolves the model passed to `gateway_complete`, edited at `/admin/settings` (there is
no dedicated quiz service, so this lives alongside `correction_model`/`modifier_model` in
`AdminSettingsForm` rather than a `ConfigField`). Blank falls back to the gateway default `molodetz`;
the gateway URL itself is never configurable per feature.
**Never trust the model.** `grading.build_result` clamps the score into `[0, 1]`, derives
`is_correct` from the *clamped* score against `QUIZ_AI_CORRECT_THRESHOLD` (never from the model's
boolean, so `correct: true, score: 0.0` cannot happen), clamps the confidence, and HTML-strips and
+7
View File
@@ -7,10 +7,12 @@ import logging
import re
from devplacepy.config import (
INTERNAL_MODEL,
QUIZ_AI_CORRECT_THRESHOLD,
QUIZ_FEEDBACK_MAX_CHARS,
QUIZ_GRADING_TIMEOUT_SECONDS,
)
from devplacepy.database import get_setting
from devplacepy.services.correction import gateway_complete
from . import scoring
@@ -45,6 +47,10 @@ def build_prompt(question: dict, answer_text: str) -> str:
)
def grading_model() -> str:
return get_setting("quiz_grading_model", "") or INTERNAL_MODEL
def grade_free_text(api_key: str, question: dict, answer_text: str) -> scoring.GradeResult:
expected = question.get("expected_answer", "") or ""
if not (api_key or "").strip():
@@ -55,6 +61,7 @@ def grade_free_text(api_key: str, question: dict, answer_text: str) -> scoring.G
SYSTEM_PROMPT,
build_prompt(question, answer_text),
QUIZ_GRADING_TIMEOUT_SECONDS,
model=grading_model(),
)
except Exception as exc:
logger.warning("Quiz AI grading failed: %s", exc)
+20
View File
@@ -172,6 +172,26 @@
<small class="hint-text">Named in the consent copy and the privacy policy, so the user knows who receives their content before they grant consent.</small>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">AI Models</h3>
<div class="admin-field">
<label for="correction_model">AI Content Correction Model</label>
<input type="text" id="correction_model" name="correction_model" value="{{ settings.get('correction_model', '') }}" maxlength="120" placeholder="molodetz">
<small class="hint-text">Model sent to the internal AI gateway for opt-in content correction. Blank uses the gateway default model.</small>
</div>
<div class="admin-field">
<label for="modifier_model">AI Modifier Model</label>
<input type="text" id="modifier_model" name="modifier_model" value="{{ settings.get('modifier_model', '') }}" maxlength="120" placeholder="molodetz">
<small class="hint-text">Model sent to the internal AI gateway for inline @ai directives. Blank uses the gateway default model.</small>
</div>
<div class="admin-field">
<label for="quiz_grading_model">Quiz AI Grading Model</label>
<input type="text" id="quiz_grading_model" name="quiz_grading_model" value="{{ settings.get('quiz_grading_model', '') }}" maxlength="120" placeholder="molodetz">
<small class="hint-text">Model sent to the internal AI gateway for free-text quiz answer grading. Blank uses the gateway default model.</small>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Policy Versions</h3>
<div class="admin-field">
+6 -3
View File
@@ -25,12 +25,15 @@
## Configuration fields
- `news_api_url` (url, default `https://news.app.molodetz.nl/api`) - the source feed, group Source.
- `news_ai_url` (url, default the internal gateway) - the chat-completions endpoint used to grade, group AI grading.
- `news_ai_model` (str, default the internal model) - the model sent to the grading endpoint.
- `news_ai_url` (url, default the free local `aquality` scoring model) - the chat-completions endpoint used to grade, group AI grading. Point it at a real generative chat model to switch to LLM-based grading (higher quality, billed).
- `news_ai_model` (str, default `aquality`) - the model sent to the grading endpoint.
- `news_grade_prompt` (text) - the exact grading rubric sent to the model; defaults to the specification below and is editable on the service page. The cleaned Title, Description and Content are appended automatically.
- `news_grade_threshold` (int, default 7, range 1 to 10) - articles whose effective score (AI grade plus the unique-image bonus minus the thin-content penalty) reaches this publish, below drafts.
- `news_ai_key` (secret) - defaults to the `NEWS_AI_KEY` env var, then the gateway internal key.
- `news_format_enabled` (bool, default on, group AI formatting) - when on, every valid article is reformatted into clean Markdown after grading.
- `news_format_enabled` (bool, default off, group AI formatting) - when on, every valid article is reformatted into clean Markdown after grading.
- `news_format_url` (url, default the internal AI gateway, group AI formatting) - the chat-completions endpoint used to reformat, entirely independent of `news_ai_url` so formatting works with a real generative model even while grading stays on the free `aquality` scorer.
- `news_format_model` (str, default the internal model) - the model sent to the formatting endpoint, independent of `news_ai_model`.
- `news_format_key` (secret, group AI formatting) - defaults to `news_ai_key`, then the gateway internal key.
- `news_format_prompt` (text, group AI formatting) - the instruction sent to the AI to reformat each cleaned article into Markdown; the Title and body are appended automatically. It must preserve every fact and output only the reformatted Markdown body.
Plus the inherited Enabled, Run interval, and Log buffer size fields.