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:
@@ -695,6 +695,9 @@ class AdminSettingsForm(BaseModel):
|
|||||||
privacy_version: str = Field(default="", max_length=20)
|
privacy_version: str = Field(default="", max_length=20)
|
||||||
guidelines_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)
|
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)
|
extra_head: str = Field(default="", max_length=50000)
|
||||||
|
|
||||||
@field_validator("moderation_filter_mode")
|
@field_validator("moderation_filter_mode")
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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 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`.
|
- **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.
|
- **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.
|
- **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.
|
**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.
|
- **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:
|
- **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.`
|
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`).
|
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`).
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from devplacepy.config import DEFAULT_MODIFIER_PROMPT
|
from devplacepy.config import DEFAULT_MODIFIER_PROMPT, INTERNAL_MODEL
|
||||||
from devplacepy.database import add_modifier_usage, get_table
|
from devplacepy.database import add_modifier_usage, get_setting, get_table
|
||||||
from devplacepy.services.ai_context import build_context
|
from devplacepy.services.ai_context import build_context
|
||||||
from devplacepy.services.background import background
|
from devplacepy.services.background import background
|
||||||
from devplacepy.services.correction import (
|
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
|
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(
|
def modify_text(
|
||||||
api_key: str, prompt: str, text: str, context: str = ""
|
api_key: str, prompt: str, text: str, context: str = ""
|
||||||
) -> tuple[str, dict | None]:
|
) -> 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"
|
"\n\n# Context (use it to inform the result; never echo this block)\n"
|
||||||
+ context
|
+ 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(
|
def schedule_modification(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from devplacepy.config import (
|
|||||||
INTERNAL_GATEWAY_URL,
|
INTERNAL_GATEWAY_URL,
|
||||||
INTERNAL_MODEL,
|
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.background import background
|
||||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||||
|
|
||||||
@@ -79,12 +79,13 @@ def gateway_complete(
|
|||||||
text: str,
|
text: str,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
max_growth_factor: int | None = None,
|
max_growth_factor: int | None = None,
|
||||||
|
model: str = INTERNAL_MODEL,
|
||||||
) -> tuple[str, dict | None]:
|
) -> tuple[str, dict | None]:
|
||||||
text = text or ""
|
text = text or ""
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
return text, None
|
return text, None
|
||||||
payload = {
|
payload = {
|
||||||
"model": INTERNAL_MODEL,
|
"model": model or INTERNAL_MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": text},
|
{"role": "user", "content": text},
|
||||||
@@ -121,6 +122,10 @@ def gateway_complete(
|
|||||||
return content, usage
|
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]:
|
def correct_text(api_key: str, prompt: str, text: str) -> tuple[str, dict | None]:
|
||||||
system = (
|
system = (
|
||||||
"You are a text correction engine. Apply the correction instruction below to "
|
"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()
|
+ (prompt or DEFAULT_CORRECTION_PROMPT).strip()
|
||||||
)
|
)
|
||||||
return gateway_complete(
|
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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
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 .embeddings import embed_texts, local_embed
|
||||||
from .llm import complete_chat
|
from .llm import complete_chat
|
||||||
from .store import Chunk, VectorStore
|
from .store import Chunk, VectorStore
|
||||||
@@ -109,7 +112,10 @@ class DeepsearchChat:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
text = await complete_chat(
|
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:
|
except Exception as exc:
|
||||||
logger.warning("deepsearch chat synthesis failed: %s", exc)
|
logger.warning("deepsearch chat synthesis failed: %s", exc)
|
||||||
|
|||||||
@@ -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`.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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`.
|
- **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.**
|
- **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.
|
- **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`).
|
- **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.
|
- **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`).
|
- **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`).
|
- **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()`.
|
- **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(
|
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]:
|
) -> list[str]:
|
||||||
emit(
|
emit(
|
||||||
{
|
{
|
||||||
@@ -80,7 +83,7 @@ async def plan_queries(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"model": INTERNAL_MODEL,
|
"model": model or INTERNAL_MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": PLANNER_PROMPT},
|
{"role": "system", "content": PLANNER_PROMPT},
|
||||||
{"role": "user", "content": query},
|
{"role": "user", "content": query},
|
||||||
@@ -132,12 +135,13 @@ async def plan_followup_queries(
|
|||||||
covered_titles: list[str],
|
covered_titles: list[str],
|
||||||
api_key: str,
|
api_key: str,
|
||||||
emit: Callable[[dict], None] = _noop,
|
emit: Callable[[dict], None] = _noop,
|
||||||
|
model: str = INTERNAL_MODEL,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
if not covered_titles:
|
if not covered_titles:
|
||||||
return []
|
return []
|
||||||
titles_block = "\n".join(f"- {title}" for title in covered_titles[:MAX_COVERED_TITLES])
|
titles_block = "\n".join(f"- {title}" for title in covered_titles[:MAX_COVERED_TITLES])
|
||||||
payload = {
|
payload = {
|
||||||
"model": INTERNAL_MODEL,
|
"model": model or INTERNAL_MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": FOLLOWUP_PROMPT},
|
{"role": "system", "content": FOLLOWUP_PROMPT},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from dataclasses import dataclass, field
|
|||||||
from itertools import zip_longest
|
from itertools import zip_longest
|
||||||
from urllib.parse import urlparse
|
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.embeddings import embed_texts, local_embed
|
||||||
from devplacepy.services.deepsearch.llm import request_completion
|
from devplacepy.services.deepsearch.llm import request_completion
|
||||||
|
|
||||||
@@ -205,11 +206,12 @@ def _page_context(pages: list) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _complete(
|
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]:
|
) -> tuple[str, dict]:
|
||||||
data, raw_usage, elapsed_ms = await request_completion(
|
data, raw_usage, elapsed_ms = await request_completion(
|
||||||
messages,
|
messages,
|
||||||
api_key,
|
api_key,
|
||||||
|
model=model or INTERNAL_MODEL,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
timeout=AGENT_TIMEOUT_SECONDS,
|
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 = [
|
messages = [
|
||||||
{"role": "system", "content": REPORT_PROMPT},
|
{"role": "system", "content": REPORT_PROMPT},
|
||||||
{"role": "user", "content": f"QUESTION: {question}\n\nSOURCES:\n{context}"},
|
{"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()
|
summary = text.strip()
|
||||||
if not summary:
|
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()
|
summary = text.strip()
|
||||||
return summary, usage
|
return summary, usage
|
||||||
|
|
||||||
|
|
||||||
async def _extract_findings(
|
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]:
|
) -> tuple[list[dict], dict]:
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": FINDINGS_PROMPT},
|
{"role": "system", "content": FINDINGS_PROMPT},
|
||||||
@@ -382,7 +386,7 @@ async def _extract_findings(
|
|||||||
]
|
]
|
||||||
usage: dict = {}
|
usage: dict = {}
|
||||||
for _attempt in range(2):
|
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 = [
|
findings = [
|
||||||
f
|
f
|
||||||
for f in (_parse_json(text).get("findings") or [])
|
for f in (_parse_json(text).get("findings") or [])
|
||||||
@@ -393,7 +397,9 @@ async def _extract_findings(
|
|||||||
return [], usage
|
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:
|
try:
|
||||||
text, _usage = await _complete(
|
text, _usage = await _complete(
|
||||||
[
|
[
|
||||||
@@ -405,6 +411,7 @@ async def _suggest_followups(question: str, summary: str, api_key: str) -> list[
|
|||||||
],
|
],
|
||||||
api_key,
|
api_key,
|
||||||
FOLLOWUP_QUESTIONS_MAX_TOKENS,
|
FOLLOWUP_QUESTIONS_MAX_TOKENS,
|
||||||
|
model,
|
||||||
)
|
)
|
||||||
questions = _parse_json(text).get("questions")
|
questions = _parse_json(text).get("questions")
|
||||||
if not isinstance(questions, list):
|
if not isinstance(questions, list):
|
||||||
@@ -423,6 +430,7 @@ async def orchestrate(
|
|||||||
emit: Callable[[dict], None],
|
emit: Callable[[dict], None],
|
||||||
store=None,
|
store=None,
|
||||||
queries: list[str] | None = None,
|
queries: list[str] | None = None,
|
||||||
|
model: str = INTERNAL_MODEL,
|
||||||
) -> Orchestration:
|
) -> Orchestration:
|
||||||
diversity = source_diversity(pages)
|
diversity = source_diversity(pages)
|
||||||
if not pages:
|
if not pages:
|
||||||
@@ -455,13 +463,15 @@ async def orchestrate(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_run_agent(emit, "summarizer", "Writing the research report")
|
_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)
|
_agent_done(emit, "summarizer", summary_usage)
|
||||||
if not summary:
|
if not summary:
|
||||||
return _heuristic(question, pages, reason="empty report from the model", emit=emit)
|
return _heuristic(question, pages, reason="empty report from the model", emit=emit)
|
||||||
|
|
||||||
_run_agent(emit, "extractor", "Extracting key findings")
|
_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)
|
_agent_done(emit, "extractor", findings_usage)
|
||||||
|
|
||||||
source_digest = _numbered_source_digest(pages)
|
source_digest = _numbered_source_digest(pages)
|
||||||
@@ -480,6 +490,7 @@ async def orchestrate(
|
|||||||
],
|
],
|
||||||
api_key,
|
api_key,
|
||||||
LINKER_MAX_TOKENS,
|
LINKER_MAX_TOKENS,
|
||||||
|
model,
|
||||||
)
|
)
|
||||||
_agent_done(emit, "linker", linker_usage)
|
_agent_done(emit, "linker", linker_usage)
|
||||||
confidence = float(_parse_json(link_raw).get("confidence", 0.0))
|
confidence = float(_parse_json(link_raw).get("confidence", 0.0))
|
||||||
@@ -495,7 +506,7 @@ async def orchestrate(
|
|||||||
score = int(
|
score = int(
|
||||||
min(SCORE_MAX, (confidence * 0.5 + diversity * 0.3 + coverage * 0.2) * SCORE_MAX)
|
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(
|
return Orchestration(
|
||||||
summary=summary,
|
summary=summary,
|
||||||
findings=findings,
|
findings=findings,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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.deepsearch.store import VectorStore
|
||||||
from devplacepy.services.jobs.base import JobService
|
from devplacepy.services.jobs.base import JobService
|
||||||
|
|
||||||
@@ -33,6 +34,16 @@ class DeepsearchService(JobService):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(name="deepsearch", interval_seconds=2)
|
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:
|
def session_dir(self, uid: str) -> Path:
|
||||||
return DEEPSEARCH_DIR / uid
|
return DEEPSEARCH_DIR / uid
|
||||||
@@ -53,6 +64,7 @@ class DeepsearchService(JobService):
|
|||||||
json.dumps({"state": "running"}), encoding="utf-8"
|
json.dumps({"state": "running"}), encoding="utf-8"
|
||||||
)
|
)
|
||||||
payload["collection"] = self.collection_name(uid)
|
payload["collection"] = self.collection_name(uid)
|
||||||
|
payload["model"] = self.get_config()["deepsearch_model"] or INTERNAL_MODEL
|
||||||
payload["cached_hashes"] = self._cached_hashes(database)
|
payload["cached_hashes"] = self._cached_hashes(database)
|
||||||
payload_path = output_dir / "payload.json"
|
payload_path = output_dir / "payload.json"
|
||||||
payload_path.write_text(json.dumps(payload), encoding="utf-8")
|
payload_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sys
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from devplacepy.config import INTERNAL_MODEL
|
||||||
from devplacepy.services.deepsearch.embeddings import embed_texts, local_embed
|
from devplacepy.services.deepsearch.embeddings import embed_texts, local_embed
|
||||||
from devplacepy.services.deepsearch.store import Chunk, VectorStore
|
from devplacepy.services.deepsearch.store import Chunk, VectorStore
|
||||||
from devplacepy.utils import generate_uid
|
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))
|
max_pages = int(payload.get("max_pages", 12))
|
||||||
depth = int(payload.get("depth", 2))
|
depth = int(payload.get("depth", 2))
|
||||||
api_key = payload.get("api_key", "")
|
api_key = payload.get("api_key", "")
|
||||||
|
model = payload.get("model") or INTERNAL_MODEL
|
||||||
collection = payload.get("collection", "")
|
collection = payload.get("collection", "")
|
||||||
cached_hashes = set(payload.get("cached_hashes", []))
|
cached_hashes = set(payload.get("cached_hashes", []))
|
||||||
should_stop = _make_stop(output_dir)
|
should_stop = _make_stop(output_dir)
|
||||||
|
|
||||||
_stage("planning", "Planning research queries", PHASE_PLANNING)
|
_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})
|
_emit({"type": "queries", "queries": queries})
|
||||||
|
|
||||||
_stage("searching", "Searching the web", PHASE_SEARCHING)
|
_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()
|
and not await should_stop()
|
||||||
):
|
):
|
||||||
covered_titles = [page.title for page in outcome.pages if page.title]
|
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:
|
if not followups:
|
||||||
break
|
break
|
||||||
round_no += 1
|
round_no += 1
|
||||||
@@ -244,7 +248,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
|
|||||||
|
|
||||||
_stage("analysis", "Running research agents", PHASE_ANALYSIS)
|
_stage("analysis", "Running research agents", PHASE_ANALYSIS)
|
||||||
result = await orchestrate(
|
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)
|
_stage("synthesis", "Compiling cited report", PHASE_SYNTHESIS)
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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.database import INTERNAL_GATEWAY_URL, get_int_setting, get_setting, internal_gateway_key
|
||||||
from devplacepy.services import pubsub
|
from devplacepy.services import pubsub
|
||||||
from devplacepy.services.base import ConfigField
|
from devplacepy.services.base import ConfigField
|
||||||
from devplacepy.services.jobs.base import JobService
|
from devplacepy.services.jobs.base import JobService
|
||||||
from devplacepy.services.jobs.isslop import store
|
from devplacepy.services.jobs.isslop import store
|
||||||
from devplacepy.services.jobs.isslop.acquisition.workspace import remove_workspace, workspace_for
|
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.events import KIND_DONE, KIND_ERROR, WorkerEvent
|
||||||
from devplacepy.services.jobs.isslop.persistence import EventPersister
|
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.",
|
help="Deterministically sampled cap on images sent to the vision model.",
|
||||||
group="Analysis",
|
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:
|
def run_dir(self, uid: str) -> Path:
|
||||||
@@ -96,7 +104,7 @@ class IsslopService(JobService):
|
|||||||
def _worker_payload(self, job: dict) -> dict:
|
def _worker_payload(self, job: dict) -> dict:
|
||||||
payload = dict(job.get("payload", {}))
|
payload = dict(job.get("payload", {}))
|
||||||
payload["llm_endpoint"] = INTERNAL_GATEWAY_URL
|
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["api_key"] = internal_gateway_key()
|
||||||
payload["allow_private"] = get_setting("isslop_allow_private", "0") == "1"
|
payload["allow_private"] = get_setting("isslop_allow_private", "0") == "1"
|
||||||
payload["ai_review"] = get_setting("isslop_ai_review", "1") == "1"
|
payload["ai_review"] = get_setting("isslop_ai_review", "1") == "1"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from devplacepy.config import INTERNAL_MODEL
|
||||||
from devplacepy.database import (
|
from devplacepy.database import (
|
||||||
add_seo_usage,
|
add_seo_usage,
|
||||||
get_seo_usage,
|
get_seo_usage,
|
||||||
@@ -81,6 +82,14 @@ class SeoMetaService(JobService):
|
|||||||
help="How many published items to enqueue for backfill each tick.",
|
help="How many published items to enqueue for backfill each tick.",
|
||||||
group="SEO metadata",
|
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:
|
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"}
|
return {"target_type": target_type, "target_uid": target_uid, "source": "plain"}
|
||||||
|
|
||||||
totals = new_usage_totals()
|
totals = new_usage_totals()
|
||||||
|
model = self.get_config()["seo_meta_model"] or INTERNAL_MODEL
|
||||||
result = await asyncio.to_thread(
|
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"]:
|
if totals["calls"]:
|
||||||
add_seo_usage(totals)
|
add_seo_usage(totals)
|
||||||
@@ -144,6 +154,7 @@ class SeoMetaService(JobService):
|
|||||||
body: str,
|
body: str,
|
||||||
defaults: dict,
|
defaults: dict,
|
||||||
totals: dict,
|
totals: dict,
|
||||||
|
model: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
context = ""
|
context = ""
|
||||||
try:
|
try:
|
||||||
@@ -161,6 +172,7 @@ class SeoMetaService(JobService):
|
|||||||
SYSTEM_PROMPT,
|
SYSTEM_PROMPT,
|
||||||
source_text,
|
source_text,
|
||||||
GENERATION_TIMEOUT_SECONDS,
|
GENERATION_TIMEOUT_SECONDS,
|
||||||
|
model=model,
|
||||||
)
|
)
|
||||||
if usage:
|
if usage:
|
||||||
for key in totals:
|
for key in totals:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file documents the automated developer-news import pipeline. Claude Code au
|
|||||||
|
|
||||||
## Overview
|
## 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
|
## 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.
|
- 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).
|
- 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 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`)
|
## 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`)
|
## AI usage metering and stats (the shared `usage.py` helpers, `news_usage`, `collect_metrics`)
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ from devplacepy.database import get_setting, internal_gateway_key
|
|||||||
logger = logging.getLogger(__name__)
|
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")
|
key = os.environ.get("NEWS_AI_KEY")
|
||||||
if key:
|
if key:
|
||||||
return key
|
return key
|
||||||
key = get_setting("news_ai_key", "")
|
key = get_setting(setting, "")
|
||||||
if key:
|
if key:
|
||||||
return key
|
return key
|
||||||
|
if fallback_setting:
|
||||||
|
key = get_setting(fallback_setting, "")
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
return internal_gateway_key()
|
return internal_gateway_key()
|
||||||
|
|
||||||
|
|
||||||
@@ -24,8 +28,10 @@ from .constants import (
|
|||||||
FEATURE_MIN_SCORE,
|
FEATURE_MIN_SCORE,
|
||||||
FORMAT_INPUT_MAX_CHARS,
|
FORMAT_INPUT_MAX_CHARS,
|
||||||
FORMAT_MAX_TOKENS,
|
FORMAT_MAX_TOKENS,
|
||||||
|
FORMAT_MODEL_DEFAULT,
|
||||||
FORMAT_OUTPUT_MAX_CHARS,
|
FORMAT_OUTPUT_MAX_CHARS,
|
||||||
FORMAT_PROMPT_SPEC,
|
FORMAT_PROMPT_SPEC,
|
||||||
|
FORMAT_URL_DEFAULT,
|
||||||
GRADE_MAX_TOKENS,
|
GRADE_MAX_TOKENS,
|
||||||
GRADE_PROMPT_SPEC,
|
GRADE_PROMPT_SPEC,
|
||||||
GRADE_THRESHOLD_DEFAULT,
|
GRADE_THRESHOLD_DEFAULT,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ AQUALITY_URL_DEFAULT = AQUALITY_NEWS_GRADING_URL
|
|||||||
AQUALITY_MODEL_DEFAULT = AQUALITY_NEWS_GRADING_MODEL
|
AQUALITY_MODEL_DEFAULT = AQUALITY_NEWS_GRADING_MODEL
|
||||||
AI_URL_DEFAULT = AQUALITY_URL_DEFAULT
|
AI_URL_DEFAULT = AQUALITY_URL_DEFAULT
|
||||||
AI_MODEL_DEFAULT = AQUALITY_MODEL_DEFAULT
|
AI_MODEL_DEFAULT = AQUALITY_MODEL_DEFAULT
|
||||||
|
FORMAT_URL_DEFAULT = INTERNAL_GATEWAY_URL
|
||||||
|
FORMAT_MODEL_DEFAULT = INTERNAL_MODEL
|
||||||
GRADE_THRESHOLD_DEFAULT = 7
|
GRADE_THRESHOLD_DEFAULT = 7
|
||||||
GRADE_MAX_TOKENS = 2000
|
GRADE_MAX_TOKENS = 2000
|
||||||
FORMAT_MAX_TOKENS = 6000
|
FORMAT_MAX_TOKENS = 6000
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ from .constants import (
|
|||||||
FEATURE_MIN_SCORE,
|
FEATURE_MIN_SCORE,
|
||||||
FORMAT_INPUT_MAX_CHARS,
|
FORMAT_INPUT_MAX_CHARS,
|
||||||
FORMAT_MAX_TOKENS,
|
FORMAT_MAX_TOKENS,
|
||||||
|
FORMAT_MODEL_DEFAULT,
|
||||||
FORMAT_OUTPUT_MAX_CHARS,
|
FORMAT_OUTPUT_MAX_CHARS,
|
||||||
FORMAT_PROMPT_SPEC,
|
FORMAT_PROMPT_SPEC,
|
||||||
|
FORMAT_URL_DEFAULT,
|
||||||
GRADE_MAX_TOKENS,
|
GRADE_MAX_TOKENS,
|
||||||
GRADE_PROMPT_SPEC,
|
GRADE_PROMPT_SPEC,
|
||||||
GRADE_THRESHOLD_DEFAULT,
|
GRADE_THRESHOLD_DEFAULT,
|
||||||
@@ -138,13 +140,44 @@ class NewsService(BaseService):
|
|||||||
default=False,
|
default=False,
|
||||||
help=(
|
help=(
|
||||||
"When enabled, every valid article is reformatted into clean "
|
"When enabled, every valid article is reformatted into clean "
|
||||||
"Markdown (paragraphs, headings, lists) after grading. The "
|
"Markdown (paragraphs, headings, lists) after grading. This uses "
|
||||||
"default grading endpoint (aquality) is a scoring-only model "
|
"its own AI formatting endpoint/model below, independent of AI "
|
||||||
"and cannot reformat text, so this defaults off; enable it "
|
"grading, so it defaults to the internal gateway's generative "
|
||||||
"only when news_ai_url points at a generative chat model."
|
"model and works even while grading stays on the free, "
|
||||||
|
"scoring-only aquality model."
|
||||||
),
|
),
|
||||||
group="AI formatting",
|
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(
|
ConfigField(
|
||||||
"news_format_prompt",
|
"news_format_prompt",
|
||||||
"Formatting prompt specification",
|
"Formatting prompt specification",
|
||||||
@@ -170,6 +203,8 @@ class NewsService(BaseService):
|
|||||||
ai_model = config["news_ai_model"]
|
ai_model = config["news_ai_model"]
|
||||||
threshold = config["news_grade_threshold"]
|
threshold = config["news_grade_threshold"]
|
||||||
format_enabled = config["news_format_enabled"]
|
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}")
|
self.log(f"Fetching news from {api_url}")
|
||||||
async with stealth.stealth_async_client(timeout=30.0) as client:
|
async with stealth.stealth_async_client(timeout=30.0) as client:
|
||||||
@@ -261,7 +296,7 @@ class NewsService(BaseService):
|
|||||||
formatted_content = ""
|
formatted_content = ""
|
||||||
if published and format_enabled:
|
if published and format_enabled:
|
||||||
formatted_content = await self._format_article(
|
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(
|
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"}
|
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:
|
if ai_key:
|
||||||
headers["Authorization"] = f"Bearer {ai_key}"
|
headers["Authorization"] = f"Bearer {ai_key}"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
**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.
|
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
|
**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
|
`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
|
boolean, so `correct: true, score: 0.0` cannot happen), clamps the confidence, and HTML-strips and
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import logging
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from devplacepy.config import (
|
from devplacepy.config import (
|
||||||
|
INTERNAL_MODEL,
|
||||||
QUIZ_AI_CORRECT_THRESHOLD,
|
QUIZ_AI_CORRECT_THRESHOLD,
|
||||||
QUIZ_FEEDBACK_MAX_CHARS,
|
QUIZ_FEEDBACK_MAX_CHARS,
|
||||||
QUIZ_GRADING_TIMEOUT_SECONDS,
|
QUIZ_GRADING_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
from devplacepy.database import get_setting
|
||||||
from devplacepy.services.correction import gateway_complete
|
from devplacepy.services.correction import gateway_complete
|
||||||
|
|
||||||
from . import scoring
|
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:
|
def grade_free_text(api_key: str, question: dict, answer_text: str) -> scoring.GradeResult:
|
||||||
expected = question.get("expected_answer", "") or ""
|
expected = question.get("expected_answer", "") or ""
|
||||||
if not (api_key or "").strip():
|
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,
|
SYSTEM_PROMPT,
|
||||||
build_prompt(question, answer_text),
|
build_prompt(question, answer_text),
|
||||||
QUIZ_GRADING_TIMEOUT_SECONDS,
|
QUIZ_GRADING_TIMEOUT_SECONDS,
|
||||||
|
model=grading_model(),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Quiz AI grading failed: %s", exc)
|
logger.warning("Quiz AI grading failed: %s", exc)
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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>
|
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Policy Versions</h3>
|
||||||
|
|
||||||
<div class="admin-field">
|
<div class="admin-field">
|
||||||
|
|||||||
@@ -25,12 +25,15 @@
|
|||||||
## Configuration fields
|
## Configuration fields
|
||||||
|
|
||||||
- `news_api_url` (url, default `https://news.app.molodetz.nl/api`) - the source feed, group Source.
|
- `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_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 the internal model) - the model sent to the grading endpoint.
|
- `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_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_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_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.
|
- `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.
|
Plus the inherited Enabled, Run interval, and Log buffer size fields.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from devplacepy.services.deepsearch.store import VectorStore
|
|||||||
|
|
||||||
|
|
||||||
def _patch_pipeline(monkeypatch, pages):
|
def _patch_pipeline(monkeypatch, pages):
|
||||||
async def fake_plan(query, api_key, emit=lambda frame: None):
|
async def fake_plan(query, api_key, emit=lambda frame: None, model=None):
|
||||||
return [query, f"{query} overview"]
|
return [query, f"{query} overview"]
|
||||||
|
|
||||||
async def fake_search(queries, emit=lambda frame: None):
|
async def fake_search(queries, emit=lambda frame: None):
|
||||||
@@ -29,7 +29,7 @@ def _patch_pipeline(monkeypatch, pages):
|
|||||||
outcome.pages.append(page)
|
outcome.pages.append(page)
|
||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
async def fake_plan_followups(query, covered_titles, api_key, emit=lambda frame: None):
|
async def fake_plan_followups(query, covered_titles, api_key, emit=lambda frame: None, model=None):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def fake_embed(texts, api_key):
|
def fake_embed(texts, api_key):
|
||||||
@@ -38,7 +38,7 @@ def _patch_pipeline(monkeypatch, pages):
|
|||||||
async def fake_embed_async(texts, api_key, **kwargs):
|
async def fake_embed_async(texts, api_key, **kwargs):
|
||||||
return local_embed(texts)
|
return local_embed(texts)
|
||||||
|
|
||||||
async def fake_orchestrate(question, crawled, api_key, emit, store=None, queries=None):
|
async def fake_orchestrate(question, crawled, api_key, emit, store=None, queries=None, model=None):
|
||||||
from devplacepy.services.jobs.deepsearch.orchestrate import Orchestration
|
from devplacepy.services.jobs.deepsearch.orchestrate import Orchestration
|
||||||
|
|
||||||
return Orchestration(
|
return Orchestration(
|
||||||
@@ -166,7 +166,7 @@ def test_worker_run_performs_refinement_round_when_budget_remains(monkeypatch):
|
|||||||
|
|
||||||
followup_calls = []
|
followup_calls = []
|
||||||
|
|
||||||
async def fake_plan_followups_once(query, covered_titles, api_key, emit=lambda frame: None):
|
async def fake_plan_followups_once(query, covered_titles, api_key, emit=lambda frame: None, model=None):
|
||||||
if followup_calls:
|
if followup_calls:
|
||||||
return []
|
return []
|
||||||
followup_calls.append(covered_titles)
|
followup_calls.append(covered_titles)
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def test_grade_free_text_uses_a_valid_verdict(monkeypatch):
|
|||||||
def test_grade_free_text_passes_the_answering_key_through(monkeypatch):
|
def test_grade_free_text_passes_the_answering_key_through(monkeypatch):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def capture(api_key, system, text, timeout):
|
def capture(api_key, system, text, timeout, model=None):
|
||||||
seen["api_key"] = api_key
|
seen["api_key"] = api_key
|
||||||
return json.dumps({"score": 1.0}), None
|
return json.dumps({"score": 1.0}), None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user