60 KiB
This file documents the async job services subsystem (devplacepy/services/jobs/ and its subdirectories deepsearch/, isslop/, seo/) - the standard pattern for running heavy blocking work off the request path. Claude Code loads it automatically whenever a file under this directory is read or edited.
Overview: the JobService pattern
The standard way to run heavy, blocking work off the request path and hand back a result URL, used by every job kind in this subsystem (zip, planning, fork, seo, seo_meta, deepsearch, isslop). Reference: admin docs Architecture -> Async job services and Services -> ZipService.
- The DB row is the queue.
services/jobs/queue.py(enqueue,get_job,touch_job,list_jobs) is pure DB and callable from any worker's handler - enqueue is a fast, non-blocking insert, status reads work from any worker, and only the lock owner processes. All kinds share ONEjobstable discriminated by akindcolumn: common lifecycle timestamps,retry_count,last_accessed_at/expires_at,bytes_in/bytes_out/item_countstat columns, pluspayload/resultJSON columns where kind-specific fields live. JobService(BaseService)runs only in the lock owner. Eachrun_once: reap finished in-flight tasks -> recover orphanedrunningrows (left by a dead owner, since there is only one processor) back topendingwith boundedretry_count-> refill up tomax_concurrentoldestpendingjobs (uuid7 sorts FIFO) asasynciotasks -> sweep rows pastexpires_atvia the per-kindcleanup()hook. In-flight tasks span ticks (kept in an instance map); the loop returns immediately each tick, so keep the interval short (default 2s).- No atomic claim is needed (single processor by construction) and no separate reaper exists - retention is built into every job service, default 7 days, admin-configurable; downloading/reading a result extends
expires_atviatouch_job. - Add a kind: subclass
JobService, setkind, implementasync process(self, job) -> dict(return theresultdict incl. stat keys) andcleanup(self, job); register the service instance inmain.py; add enqueue endpoints that own authz, a status route, a download/report route, a Devii tool, and docs. A newJobServiceneeds a server restart to go live. - Permanent vs expiring artifacts (load-bearing distinction - decide this per kind). Most kinds produce a DISPOSABLE artifact that expires with the retention sweep:
ZipService,SeoService, andDeepsearchServiceall delete their real output (archive / report+screenshots / vector collection+report) insidecleanup(). Two kinds are different:ForkService's artifact is a permanent project, and the AI Usage Analyzer's (isslop) artifacts (analysis, events, file/image results, report, badge) are permanent public capability URLs - both makecleanup()a no-op on the real artifact and let the retention sweep remove only thejobstracking row. Get this decision right for any new kind: if the output should outlive the job the way a fork or an isslop report does, do not wirecleanup()to delete it.
ZipService (kind zip)
processmaterializes a project subtree viaproject_files.export_to_dir(staging underconfig.DATA_DIR/zip_staging/{uid}), compresses it in a subprocess (python -m devplacepy.services.jobs.zip_worker, stdlib-only, prints stats JSON incl. crc32), names the output{crc32}.{slug}.zipunderconfig.DATA_DIR/zips/{uid[-2:]}/{uid[-4:-2]}/(sharded on the random tail of the uuid7 viaattachments._directory_for, NOT its time-ordered head), and removes staging.- Runtime artifacts live in
DATA_DIR(data/by default), OUTSIDE the package and NOT under/static; the download is served by the/zips/{uid}/downloadroute viaFileResponse, not the static mount. - Enqueue endpoints own authz:
POST /projects/{slug}/zip,POST /projects/{slug}/files/zip?path=. GET /zips/{uid}(statusZipJobOut) andGET /zips/{uid}/download(FileResponse, extends expiry viatouch_job) are capability URLs scoped only by the unguessable uuid7, not by owner - the owner is stored for attribution, not access control, matching publicly-viewable projects.- Frontend
app.zipDownloader(static/js/ZipDownloader.js) auto-wires anydata-zip-downloadelement plus the files context menu: POST -> poll/zips/{uid}via the sharedJobPoller-> trigger download. - Devii tools
zip_project/zip_status; CLIdevplace zips prune|clear. - Disposable:
cleanup()deletes the archive and the retention sweep prunes both the file and thejobsrow.
Planning report generator (kind planning, services/jobs/planning_service.py)
PlanningReportService is admin-only and builds a complete, phased markdown implementation document from a selectable set of open Gitea tickets, intended to be handed straight to a coding agent for one-shot execution, off the request path via the same async-job pattern as zip.
processcollects open tickets viaservices/gitea/planning.pycollect_open_issues(client, limit=MAX_ISSUES)(the paginatedlist_issues(state="open", limit=50)loop, cap 50; reused by the admin page too), narrows them to the selected ticket numbers carried in the job payload ({"numbers": [int, ...]}, preserving selection order - an empty/absent list keeps all open, so the no-arg Devii action and any legacy enqueue stay backward compatible), and builds the markdown viagenerate_plan(issues, config)(an AI-or-fallback helper mirroringenhance.py).- AI path: passes each ticket's FULL, verbatim description (
_body, capped only at theBODY_MAX=40000per-ticket safety limit, not the old 600-char excerpt) to the internal gateway viastealth.stealth_async_clientwith a high output budget (MAX_TOKENS=32000,PLAN_MAX=600000final cap,PLANNING_TIMEOUT_SECONDS=600client timeout) and aSYSTEM_PROMPTthat demands a self-contained document: a## Execution Orderlist, then## Phase K: <name>headings, and under each phase a### #N <title>subsection per ticket carrying labelled Original ticket (verbatim blockquote), Goal, Dependencies, Affected areas / files, Implementation steps, Acceptance criteria, and Risks / open questions blocks - preserving every detail with nothing summarised away. - Because an LLM can still summarise or truncate, the AI document is never trusted to be complete on its own:
generate_planalways appends a deterministic# Appendix: Source Tickets (verbatim)section built straight from the issue dicts byplanning.pyverbatim_tickets(issues)(per ticket## #N <title>, labels, thehtml_urlsource link, and the full body as a blockquote), so every covered ticket's full text is guaranteed present inline AND in the appendix - the document is genuinely self-contained. - Fail-soft
_fallback(whenai_enhanceis off or anyhttpx.HTTPError/ValueError/KeyError/IndexErroroccurs) groups by primary label then ticket number, emitting the same## Execution Order+## Phase Kshape with each ticket's full description reproduced verbatim (already self-contained, so no extra appendix). - Writes the markdown to
config.PLANNING_REPORTS_DIR/_directory_for(uid)/{crc32}.{slug}.md(sharded on the uuid7 random tail, crc32 over the markdown bytes), and returns{download_url, local_path, final_name, markdown, ai_used, item_count, bytes_out}. Themarkdownstring is carried in the result so the status route can hand it to the renderer without re-reading disk.cleanupunlinks the file (retention prunes only the artifact + job row). - Audit
issue.planning.request(enqueue) andissue.planning.generate(success/failure) viarecord/record_systemwith anaudit.job(uid)link. - Routes (all
require_admin):POST /issues/planning(enqueue,503when Gitea unconfigured,PlanningFormwith a comma-separatednumbersfield parsed tolist[int]and stored in the payload, returns{uid, status_url}),GET /issues/planning/{uid}(PlanningJobOut: status,markdown,download_url,issue_count,ai_used),GET /issues/planning/{uid}/download(FileResponse,text/markdown, traversal-guarded againstPLANNING_REPORTS_DIR, extends retention viatouch_job). The/issues/{number}detail route uses the{number:int}path converter so/issues/planningis never captured by it. - Admin entry point:
GET /admin/issues/planning(routers/admin/issues.py,admin_issues_planning.html) fetches the open tickets server-side viacollect_open_issues(wrapped so a Gitea/network failure renders an empty state, never a 500) and renders a selectable checkbox list (all checked by default, a select-all/none master, and a live selected count) as the step in between, plus the Generate planning button, theJobPollerstatus panel, the<dp-content>render target, and a Download anchor; the issues listing shows an admin-only Generate planning link (viewer_is_admininissues_page). - Frontend driver
static/js/PlanningGenerator.js(app.planningGenerator): tracks the checkbox selection (master toggle, count, disables Generate at zero selected), sends the chosen numbers as thenumbersform field, POST ->JobPoller.run(widened tointervalMs:2000, maxAttempts:400= ~800s so it outlives the longer detailed generation) -> replace the<dp-content>with a fresh one holdingstatus.markdownso it re-renders, set the download href. - Devii tool
planning_report_generate(requires_admin=True). The renderer's built-in copy button (thedp-contentCopy button styled inmarkdown.css, identical to the per-code-block copy button) satisfies the copy-source requirement and is opt-out via theno-copyboolean attribute on<dp-content>.
Project fork - ForkService (kind fork, services/jobs/fork_service.py)
Forking copies a source project into a brand-new project owned by the forking user, off the request path via the same async-job pattern as the zip flow.
- Enqueue:
POST /projects/{slug}/fork(routers/projects/index.py) -require_userthencan_view_project(source, user)(any logged-in user may fork any project they can view: public projects and their own private ones). Body isForkForm{title}(the destination project name). It enqueues aforkjob ({source_project_uid, title, forked_by_uid}, owner("user", uid)) and returns{uid, status_url}. No work happens on the request path. processlooks up the source project row and the forking user row (raises -> jobfailedif either is missing), creates the destination project withcontent.create_content_item("projects", "project", user, fields, ...)(so slug/XP/logging match a normal create; metadata -description,project_type,platforms,status, dates,is_private- is copied from the source,read_onlyreset to 0), copies the whole virtual FS off-thread (export_to_dir(source, "", staging)->import_from_dir(new_uid, staging, user, skip_names=set()), an exact copy including binaries, staging underconfig.DATA_DIR/fork_staging/{uid}), then records the relation withdatabase.record_fork(source_uid, new_uid, forked_by_uid). Result:{project_uid, project_url, source_project_uid, item_count}.- Rollback: any failure after the project is created triggers
_rollback(new_uid)(project_files.delete_all_project_files+delete_fork_relations+ delete theprojectsrow) before re-raising, so a failed fork leaves no orphan project. - Cleanup is a no-op on the project. Unlike zip's disposable archive, a fork's artifact is a permanent project that must outlive the job.
cleanup()only removes any leftover staging dir; the retention sweep deletes the job tracking row, never the forked project.devplace forks prune|clearlikewise deletes job rows only. - Relation model: the
project_forkstable (uid,source_project_uid,forked_project_uid,forked_by_uid,created_at) records direction explicitly (source -> forked), indexed on both project columns. Helpers indatabase.py:record_fork,get_fork_parent(forked_uid)(the source project row, for the "Forked from X" link on the project detail page),count_forks(source_uid), anddelete_fork_relations(project_uid)(called on project delete incontent.delete_content_item, and in fork rollback). - Frontend:
app.projectForker(static/js/ProjectForker.js) wiresdata-fork-project(a Fork button on the project detail page, visible to any logged-in user): prompt for a name viaapp.dialog.prompt->Http.sendFormPOST ->JobPoller.run("/forks/{uid}", ...)-> ondoneredirect toproject_url. The forked project's detail page shows a "Forked from X" link (database.get_fork_parent). - Status route
GET /forks/{uid}(routers/forks.py,ForkJobOut) exposesproject_uid/project_url/source_project_uidonly oncestatus == done. Devii toolsfork_project/fork_status; docsprojects-fork/forks-status.
SEO Diagnostics tool - SeoService (kind seo, services/jobs/seo/, routers/tools/)
The public Tools -> SEO Diagnostics auditor crawls a URL or sitemap with a headless browser and runs a broad battery of SEO checks, on the same async-job pattern as zip/fork plus a live websocket. It is public (guests included); abuse is bounded by the per-IP POST rate limit, a per-owner one-active-job cap, a page cap, and the shared SSRF guard.
- Surface: a collapsible Tools dropdown in
base.html(desktop center nav + a mobile section, visible to everyone) toggled byMobileNav.initToolsDropdown.GET /toolslists tools;GET /tools/seois the auditor page (static/js/SeoDiagnostics.js->app.seoDiagnostics, instantiated page-side in the template, not inApplication.js). - Enqueue:
POST /tools/seo/run(routers/tools/seo.py, bodySeoRunForm{url, mode: url|sitemap, max_pages 1-50}). Owner is("user", uid)or("guest", X-Real-IP). It rejects with429if the owner already has a pending/runningseojob, then enqueues{url, mode, max_pages, allow_private:False}and returns{uid, status_url, ws_url}. processwrites the payload toconfig.SEO_REPORTS_DIR/{uid}/payload.json, launchespython -m devplacepy.services.jobs.seo.worker <payload_json> <output_dir>viacreate_subprocess_exec(highlimit=so big lines never overflow the StreamReader), reads NDJSON frames from stdout line by line (stage/target/progress/page/site_checks/report_ready), forwards each into the in-processProgressHub(services/jobs/seo/progress.py, uid -> set ofasyncio.Queue), and on completion loadsoutput_dir/report.jsonas the job result.cleanup()clears the hub buffer and removes the report dir.- Worker (
worker.py, subprocess):crawler.crawl_targetresolves the target (single URL, or sitemap<loc>URLs capped atmax_pages) and fetchesrobots.txt/sitemap.xml/llms.txtwithhttpx. The audited target host is guarded once withnet_guard.guard_public_url; candidate URLs sharing that host are pre-approved (no redundant per-URLgetaddrinfo- a transient DNS failure or a self-hosted server resolving its own domain must not blank the whole crawl), and only cross-host sitemap entries are re-guarded. In sitemap mode the crawler never falls back to auditing the sitemap document itself: if no page URLs survive it raises a clear error (a strayor [target]fallback previously rendered the sitemap XML as one 56k-node "page" with no title/H1). For each page it launches one Playwright navigation: a singlepage.evaluate(EXTRACT_SCRIPT)returns the whole DOM contract (title/metas/canonical/headings/images/links/jsonld/og/twitter/semantic/mixed-content/word-count), an injectedPerformanceObserver(add_init_script(INIT_SCRIPT)) captures LCP/CLS, navigation timing gives TTFB/FCP/transfer/protocol, a mobile-viewport pass measures overflow/tap-targets, a screenshot is saved, and a rawhttpxGET supplies the SSR HTML for the rendered-vs-server parity check. - Check registry (
checks/):base.pydefines theCheck/PageContext/SiteContextdataclasses and the@page_check/@site_checkdecorators (collected intoPAGE_CHECKS/SITE_CHECKS); one module per category (crawl,meta,headings,links,structured_data,social,performance,mobile_a11y,security,ai_readiness,crosspage).registry.run_page_checks/run_site_checksrun them defensively (one failing check never aborts a page), andcompute_scoreproduces a severity-weighted overall score + grade and per-category subscores. To add a check: write a function decorated@page_check/@site_checkin the right category module and import that module inregistry.py. - Live progress WS:
WS /tools/seo/{uid}/wsis served only by the service-lock owner (closes4013for a fast retry on a non-owner worker, like/devii/ws); it replayshub.snapshot(uid)then streamshub.subscribe(uid). The frontendSeoProgressSocketmirrors theDeviiSocket4013/reconnect pattern. Thedoneframe carries the full report inline (and the router's late-join terminal branch reads it fromjob.result); the client renders fromframe.reportdirectly. This is load-bearing:service.processpublishesdonefrom insideprocess(), BEFORE the JobService base persists the result on the next reap tick (~2s later), so a client that fetched/tools/seo/{uid}/reporton thedonesignal would race the DB write and get an empty (all-zero) report. Do not "simplify" this back into a fetch-on-done. After publishingdone,process()callshub.clear(uid)to bound the in-memory buffer (late reconnects fall back to the DB-backed terminal branch). - Status/report routes:
GET /tools/seo/{uid}(SeoJobOut),GET /tools/seo/{uid}/report(respond(..., SeoReportOut), HTML or JSON),GET /tools/seo/{uid}/screenshot/{n}(FileResponse fromSEO_REPORTS_DIR, path-guarded). All are capability URLs scoped by the unguessable uuid7. The full report is written toconfig.SEO_REPORTS_DIR/{uid}/report.json(NOT inlined on a stdout line - avoids the StreamReader limit). Devii toolsseo_diagnostics/seo_status/seo_report(public); docstools-seo-*; CLIdevplace seo prune|clear; auditseo.run.request|complete|failed(categorytools). - SSRF guard is shared:
devplacepy/net_guard.py(guard_public_url,is_blocked_address,effective_address) was extracted from the Devii fetch controller, which now imports it; the crawler reuses it.playwrightis a core dependency (Chromium installed bymake install/ the Docker image). - Disposable:
cleanup()removes the report dir; retention prunes both the artifacts and thejobsrow. - Production nginx needs a dedicated WS location. In
nginx/nginx.conf.templatethe catch-alllocation /setsConnection ""(no upgrade) and a 60s timeout, so any websocket route that falls through to it fails the handshake (browser:WebSocket connection failed, no close code). The progress socket has its ownlocation ~ ^/tools/seo/[^/]+/ws$block forwardingUpgrade/Connectionwith a 1h timeout, mirroring/devii/ws. Any new websocket path must add its own upgradelocationabovelocation /(see docsProduction -> nginx).
SEO metadata service - SeoMetaService (kind seo_meta, services/jobs/seo_meta_service.py, services/seo_meta.py, seo_meta_text.py)
SeoMetaService generates a clean, SEO-optimized title/description/keywords for every published content item (types post, project, gist, news, issue) off the request path and meters its own AI spend. It is a distinct concern from the public Tools -> SEO Diagnostics auditor (kind seo) - do NOT conflate the two kinds; they share only the jobs queue table. It is the constructive counterpart to the diagnostics tool: diagnostics audits a URL, this one populates the on-page metadata. It is a JobService like ForkService: the artifact (the seo_metadata row) is permanent, so cleanup() is a no-op and the retention sweep removes only the job tracking row.
- Tables.
seo_metadatais polymorphic and soft-deletable (inSOFT_DELETE_TABLES, born-livedeleted_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 UNIQUEidx_seo_metadata_targetand the liveidx_seo_metadata_status (status, deleted_at)index.seo_usageis a single-row config-like usage table (NOT soft-delete) mirroringnews_usage, keyedSEO_USAGE_KEY="seo_meta". Helpers indatabase.py:get_seo_metadata/get_seo_metadata_batch/has_fresh_seo_metadata/upsert_seo_metadata/mark_seo_metadata_stale(every read filtersdeleted_at IS NULL;get_seo_metadatareturns onlystatus="ready"live rows) andadd_seo_usage/get_seo_usage. - Choke helper.
services/seo_meta.pyschedule_seo_meta(target_type, uid, regenerate=False)andschedule_seo_meta_for_table(table, uid, ...)are import-cycle-free (onlydatabase+queue). They no-op for unknown types, missing uid, or (withoutregenerate) when a freshreadyrow exists (database.has_fresh_seo_metadata);regenerate=Truemarks the row stale first (database.mark_seo_metadata_stale); both skip a target with an existing pending/runningseo_metajob; otherwisequeue.enqueue("seo_meta", {target_type, target_uid}, "system", "seo_meta"). Hooked atcontent.create_content_item(create, no-op guard) andcontent.edit_content_item(regenerate), the news publish sites inservices/news.py(status=="published"only; existing-row update path usesregenerate=True), andIssueCreateServiceafter the Gitea ticket is recorded. Because the work is async via the queue (NOTrun_in_executor), the helper only enqueues. processloads the target row (posts/projects/gists/news viaget_table, issues viagitea.store.get_ticket), builds grounding viaservices/ai_context.build_context(fail-soft for news/emptyuser_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 viato_threador it self-deadlocks the single worker - the same lesson ascorrection.py's sync mode). It demands strict JSON{seo_title, seo_description, seo_keywords}, parses fail-soft, re-clamps every field server-side viaseo_meta_text.clamp_generated, and falls back toseo_meta_text.plain_seo_defaults(statusfailed, sourceplain) on any failure - the fields are never empty. Usage accumulates viacorrection.new_usage_totalsand flushes once withdatabase.add_seo_usage(totals)whencalls>0(the single-rowseo_usagetable, mirroringnews_usage). It emits an auditseo.meta.generate/seo.meta.failed(record_system, categorytools) andupsert_seo_metadata(...).- Backfill.
run_oncecallssuper().run_once()then a bounded backfill sweep (gated byseo_meta_backfill_enabled,seo_meta_backfill_batchper type per tick) over published content lacking a freshreadyrow, so pre-existing items get metadata with no one-shot migration. - Admin surface.
collect_metrics()merges theJobServicejob-pipeline stats withusage_metric_cards(get_seo_usage()), so the SEO Metadata card on/admin/servicesshows both the live task pipeline and the AI cost/averages; the existingadmin.services.{name}pub/sub topic +live_view_relayrow pushes it live with NO new VIEWS row. A standard-paginated task list readsqueue.list_jobs(kind="seo_meta")withdatabase.build_pagination+_pagination.htmlwhen a dedicated page is desired. - Clamps (single source of truth,
seo_meta_text.py):seo_titlehard cap 60 (word-boundary, single hyphen, keyword front-loaded);seo_descriptionhard cap 160 (word-boundary viaseo.truncate, key message in the first 120 chars);seo_keywords5-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_markdownreusesrendering._render_content+utils.strip_htmlso markdown (and em-dash) never leaks. - SEO consumption fix (
seo.pybase_seo_context). The meta description is now markdown-stripped (plain_markdown/plain_text_from_markdown, fixing the prior raw-markdown leak),meta_keywordsis emitted (a safe plain string, NOT a Jinja-global name), and aseo_target=(target_type, target_uid)param makes it consume the readyseo_metadatarow when present, elseplain_seo_defaults.base_seo_context's newkeywords/seo_targetparams are OPTIONAL with safe defaults so existing callers are unaffected; the five detail routers (posts/projects/gists/news/issues) passseo_target.og_title/twitter:titleuse the bareseo_title(drops the redundant " - DevPlace" suffix in social cards);base.htmladds<meta name="keywords">,og:image:width/height/altandtwitter:image:alt. The per-type JSON-LD (discussion_forum_posting,software_application_schema,news_article_schema,software_source_code_schema) route their text/description throughplain_markdown. - Fan-out. Schema
SeoMetaOut; read routeGET /tools/seo-meta/{target_type}/{target_uid}(routers/tools/index.py, public, JSON) returning the ready row or a plain default with statuspending; Devii actionseo_meta_status(public, read-only) + docstools-seo-meta-status; CLIdevplace seo-meta prune|clear(job rows only; the metadata persists); eventsseo.meta.generate|failed. Registered inmain.pyalongsideSeoService. A newJobServiceneeds a server restart to go live.
DeepSearch tool - DeepsearchService (kind deepsearch, services/jobs/deepsearch/, services/deepsearch/, routers/tools/deepsearch.py)
The public Tools -> DeepSearch researcher is a multi-agent deep web researcher built on the same async-job + ProgressHub + 4013-WS pattern as the SEO tool, plus a per-session vector store and a grounded RAG chat. It is public (guests included); abuse is bounded by the per-IP POST rate limit, a per-owner one-active-job cap, a page cap (1-30), depth cap (1-4), and the shared SSRF guard. Reuse the SEO tool as the template for any new Tools async job.
- Owner helper is shared:
routers/tools/_shared.pyowner_for(request)returns("user", uid)or("guest", X-Real-IP); bothseo.pyanddeepsearch.pyimport it (do not re-inline the owner derivation). - Enqueue:
POST /tools/deepsearch/run(bodyDeepsearchRunForm{query, depth 1-4, max_pages 1-30}). It rejects with429if the owner already has a pending/runningdeepsearchjob. It resolves the logged-in user'susers.api_key(guests usedatabase.internal_gateway_key()) into the job payload for per-user embedding/LLM spend attribution, generates the uid up front, writes adeepsearch_sessionsrow (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(notqueue.enqueue) so the session uid and the job uid match. processwritescontrol.json(staterunning) +payload.json(augmented with the cross-sessioncached_hashes) underconfig.DEEPSEARCH_DIR/{uid}, launchespython -m devplacepy.services.jobs.deepsearch.worker <payload_json> <output_dir>viacreate_subprocess_exec(highlimit=), pumps NDJSON stdout frames into the in-processProgressHub(progress.py), and on completion loadsoutput_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.- 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, neverPlatformClient; per-query result buckets are round-robin interleaved so every planned angle contributes pages, never just the first query) ->crawl.crawl(batches ofCRAWL_CONCURRENCYconcurrent httpx fetches then playwright render fallback,guard_public_urlon the URL and every redirect, content-hash + URL-hash dedup;depthfollows in-page links: after each level the links of every crawled page are scored by query-token overlap viaextract.relevant_linksand the topLINKS_PER_PAGEunseen ones form the next level,depth=1disables 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 writesreport.jsonandurl_cache.jsonand emits areport_readyframe carryingsynthesis. - Search-provided content is a first-class source (
crawl.py, the second junk-report fix).search_queriescalls rsearch withcontent=true, so each candidate carries the search engine's own readablecontent/descriptionextract. 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. Nowcrawl._resolve_candidateskips the fetch entirely for a hostile domain and uses the rsearch snippet (_snippet_page,source="search",SNIPPET_MIN_CHARSfloor), 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 revertcontent=trueand never send a headless render at aHOSTILE_DOMAINShost. - Content extraction (
extract.py, stdlib only):extract_html(raw, base_url)is a readability-gradeHTMLParserextractor 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 skipsscript/style/nav/header/footer/aside/formand ARIArole=navigation|banner|contentinfo|...regions, prefers<article>/<main>when they carry at leastMIN_CONTENT_TOTALchars, drops link-dense blocks (MAX_LINK_DENSITY, menus) and sub-MIN_BLOCK_CHARSfragments, unescapes entities, and emits real paragraphs joined by blank lines - which also makeschunking.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. - No "gaps"/critic agent (removed - do not reintroduce). DeepSearch used to run a fourth "critic" agent that produced an "Open gaps" list. It was removed end-to-end (orchestrate, worker report,
DeepsearchSessionOut, router context, session template, markdown/HTML export,DeepsearchTool.jsagent labels, CSS) because it routinely emitted misleading, self-contradictory gaps on correctly-cited reports (the historic cause was that the critic was fed a truncated, retrieval-ordered source slice that dropped cited source numbers out of its window, so it fabricated "citation [n] is not in the report / no evidence provided"). Do NOT reintroduce agapsfield or a critic agent. The_numbered_source_digest(pages)helper survives and is used by the linker - a compact per-source[n] title (url)\nexcerptblock for EVERY page where the header line is always emitted even when the excerpt is trimmed, so all source numbers1..Nare guaranteed present (never pass a rawcontext[:N]slice to an agent that reasons about source numbers). Regression:tests/unit/services/jobs/deepsearch/orchestrate.py::test_linker_receives_full_source_list/::test_numbered_source_digest_keeps_every_source_number_under_cap. - Orchestration (
orchestrate.py) is retrieval-grounded and markdown-first. The worker indexes BEFORE analysis and passes theVectorStore+ planned queries toorchestrate, which embeds the question and each sub-query and pullshybrid_searchtop chunks (round-robin merged, up toCONTEXT_CHUNKS_MAX), building the context from the RETRIEVED passages grouped per source - the source numbers[n]align with the report'ssourceslist (page order), so inline citations, finding citations, and the rendered numbered source list agree. Page-head excerpts are only the fallback when retrieval is empty. Synthesis is two-step to avoid the markdown-inside-JSON trap: the summarizer writes a plain markdown report (REPORT_MAX_TOKENS, retried once if empty), then a separateextractoragent returns the findings JSON (retried once, tolerant_parse_jsonhandles code fences and trailing garbage); the linker (confidence) failure is caught and never discards the report. The three agents in the pipeline today are summarizer, extractor, and linker - there is no fourth "critic" stage. Only a failed/empty report falls back to_heuristic, which stampssynthesis="heuristic"and emits astatus:"failed"agent frame - the degradation is VISIBLE:report.synthesisflows throughDeepsearchSessionOut.synthesis, the session template renders a "Degraded report" banner (.ds-degraded), and the markdown export carries the same note. A successful run stampssynthesis="agents". Never re-inline synthesis into a single JSON blob and never let a synthesis failure ship silently. - PDF ingestion (
crawl.pyfetch_page+pdf.py): a crawled candidate is treated as a PDF when itscontent-typeisapplication/pdf/application/x-pdf, its URL path ends in.pdf, or its first bytes match the%PDF-magic (pdf.is_pdf).fetch_pagestreams the body and caps it atMAX_PDF_BYTES(15 MB); for a PDF it callspdf.extract_pdf_text, which writes the raw binary to atempfiletemp location (cleaned up viaPath.unlink(missing_ok=True)infinally), parses it withpypdf(PdfReader, capped atMAX_PDF_PAGES= 50, title pulled from metadata), and normalizes whitespace. The resultingCrawledPagecarriessource="pdf"; PDFs skip the Playwright fallback. Everything downstream is source-agnostic (chunking/embedding/orchestration readpage.text/page.sourceunchanged), so no other module changes. New unpinned deppypdf(pure-python, no system deps). - Pause/resume/cancel:
POST /tools/deepsearch/{uid}/{pause|resume|cancel}(owner-gated) rewritecontrol.json; the worker'sshould_stopcallback polls it between source fetches (paused = sleep-loop, cancelled = stop). State lives in a file, not the job row, so the running-in-a-subprocess worker can read it without a DB round-trip. - Vector store (
services/deepsearch/store.py):VectorStorewrapschromadb.PersistentClient(path=config.DEEPSEARCH_CHROMA_DIR), one collection per session (ds_<uid>).Chunkis the dataclass.hybrid_searchblends cosine vector similarity with a BM25 keyword score (weightsHYBRID_VECTOR_WEIGHT/HYBRID_KEYWORD_WEIGHT) over the candidate set, with optional metadatawherefilters.embeddings.pyembed_textscalls the gateway embeddings endpoint and falls back to a deterministic local hashing vector on any failure (so the tool degrades, never breaks). - RAG chat (
services/deepsearch/chat.py+WS /tools/deepsearch/{uid}/chat): a dedicated lightweight loop (NOT the Devii hub), served only by the service-lock owner (closes4013for fast retry). Answers are grounded ONLY in the session collection viahybrid_search, cited inline, rendered client-side viadp-content. Turns persist todeepsearch_messagesand auditdeepsearch.chat. Frontend component<dp-deepsearch-chat>(static/js/components/AppDeepsearchChat.js) clonesAppDocsChat's framing but uses its own WebSocket to the chat path. - Status/report/export routes:
GET /tools/deepsearch/{uid}(DeepsearchJobOut),GET /tools/deepsearch/{uid}/session(respond(..., DeepsearchSessionOut), HTML or JSON),GET /tools/deepsearch/{uid}/export.{md,json,pdf}(services/deepsearch/export.py; PDF via weasyprint). All are capability URLs scoped by the unguessable uuid7. Viewer-flag discipline: the session schema/context useviewer_is_admin/viewer_owns(neveris_admin/owns) so arespond()context key never shadows a Jinja global (the same class of issue as the issues/{number}route).tests/api/tools/deepsearch/session.pyguards the HTML render. - Completion race (load-bearing read-path fix). The worker writes
report.jsonto disk andservice.processpublishes thedoneframe from insideprocess(), but theJobServiceframework only commitsjobs.result/status=DONEafterwards, in_reap()->_finish_done()on a later tick. The frontend navigates to the session page the instant it receivesdone, so a read that keyed only offjobs.status == DONEreturned an EMPTY report (Nonescore, 0 sources) until a manual refresh. Fix:_report_for(uid, job)returnsjob.result.reportwhen the job isDONEand non-empty, else falls back to the on-diskreport.json(_report_from_disk,DEEPSEARCH_DIR/{uid}/report.json) - which exists before thedoneframe is ever sent - and returns{}only for aFAILEDjob or a genuinely still-running job with no report on disk._session_contextderivesdone/statusfrombool(report)(not raw job status), and the chat WS gate acceptssession.status == "done"(set insideprocess()before the publish) as ready._export_reportreuses the same fallback. Regression:tests/api/tools/deepsearch/session.py::test_session_reads_disk_report_before_result_commit. Any new read of a job result that a client reaches immediately after adone/session_urlframe must use this same on-disk fallback, never barejobs.status. - Clickable inline citations (
services/deepsearch/citations.py). The report/findings carry[n]markers (and the model sometimes emits[3][9][1-2]); thelink_citations(html, source_count)template global (registered intemplating.py) rewrites each[n]and each[a-b]range into<a class="ds-cite" href="#ds-source-n">[n]</a>anchors that jump to the numbered<li id="ds-source-n">in the Sources list (source numbering is page order, matching the[n]the summarizer was given). It splits out<a>/<code>/<pre>regions first so markers inside links/code are left alone, expands ranges to individual links, and drops out-of-range numbers (no broken anchors). The session template nests it over the server render:{{ link_citations(render_content(summary), sources|length) }}and{{ link_citations(finding.detail|e, sources|length) }}, plus a per-finding.ds-finding-citeschip row fromfinding.citations..ds-cite/.ds-sources li:targetstyling lives indeepsearch.css. The report prompt asks for one number per bracket (never a range) so output is consistent, but the linkifier handles ranges regardless. Regression:tests/unit/services/deepsearch/citations.py. - Tables (
deepsearch_sessions,deepsearch_messagessoft-deletable + inSOFT_DELETE_TABLES;deepsearch_url_cacheGC-only): columns are ensured ininit_db()(every queried column) with indexes. Every insert writesdeleted_at:None/deleted_by:None; every read filtersdeleted_at IS NULL. - Frontend (do not hand-roll):
DeepsearchTool.js(app.deepsearchTool) drives the form viaHttp.send, watchesDeepsearchProgressSocket(cloned fromSeoProgressSocket, 4013 retry), and wires pause/resume/cancel.static/css/deepsearch.cssuses the design tokens and is mobile-responsive. - Progress frame protocol (append-only, the worker<->JS contract):
phases.pyis the single source of truth for phase identity, shared byworker.py(emit) andDeepsearchTool.js(render).PHASE_ORDER = [planning, searching, crawling, indexing, analysis, synthesis];worker._stage(stage, message, phase)emits BOTH the legacystageframe (byte-identical to before) AND a parallelphaseframe{phase, index, total, label}so the timeline strip advances. The first emitted frame carriesversion:1. Every other frame type and its keys:substep(planning angles,phase+message; also emitted by analysis grounding),queries,candidates,rsearch,progress(done/total/url/depth),page_loaded(nowsource/render/depth/elapsed_ms/done/total),page_cached/page_skipped/page_duplicate(nowreason/elapsed_ms),embed_batch(batch/total_batches/backend/done/total, emitted before AND after each batch),embed_done(backend/chunk_count),agent(agentone of summarizer|extractor|linker,stage/statusstart|done|failed, withelapsed_ms/tokens_in/tokens_outon done),report_ready(now alsosynthesis),done(session_url),failed(message). The contract is append-only: never rename or drop a frame type;service._run_workerpumps every stdout line into theProgressHubuntouched, so new frame types reach the WS with no handler change.tests/api/tools/deepsearch/index.pyis the append-only regression guard. - RAG-audit hardening (engine correctness, no route/schema change): (1) Embedding-dimension consistency - gateway embeddings carry provider-native dims while
local_embedis fixed 256-dim; the worker_index_chunksnow decides the backend ONCE per job (the first gateway failure or non-gateway result forces local for ALL remaining batches), andVectorStore.adddrops any vector whose length differs from the collection's established dim, so one collection never mixes dims (cosine search across mixed dims is corrupt).embeddings.EmbedResult.dimsandVectorStore.dims(lazily probed from the collection) expose the dimension;chat.retrievere-embeds the query locally and skips retrieval if it still cannot match the stored dim. (2) Citation grounding -orchestratedrops any finding with no citations, and the summarizer prompt forbids uncited claims and treats the QUESTION as data not an instruction (prompt-injection reduction via_sanitize_question);chat._strip_unmatched_markersremoves any inline[n]marker that does not map to an emitted citation. (3) Confidence calibration - when only a single domain was crawled,orchestratecaps confidence by the source-diversity-derived ceiling (overconfidence guard). (4)EmbeddingCacheis bounded atEMBED_CACHE_MAXto cap in-memory growth. - Devii tools
deepsearch/deepsearch_status/deepsearch_session(public); docstools-deepsearch; CLIdevplace deepsearch prune|clear; auditdeepsearch.run.request|complete|failed+deepsearch.chat(categorytools). New dependencies:chromadb,weasyprint,pypdf(all unpinned). New runtime dirsconfig.DEEPSEARCH_DIR/DEEPSEARCH_CHROMA_DIRare registered inDATA_PATHS. Add a dedicated nginx WSlocationfor/tools/deepsearch/{uid}/wsand/chatabovelocation /for production, like the SEO and Devii sockets.
AI Usage Analyzer tool - IsslopService (kind isslop, services/jobs/isslop/, routers/tools/isslop.py)
The public Tools -> AI Usage Analyzer classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. It is built on the standard async-job pattern (a JobService running a subprocess worker), but its live channel is pub/sub, not a dedicated WS route: every worker event is published to public.isslop.{uid} AND persisted to isslop_events, and the frontend pairs the pub/sub subscription with an incremental GET /tools/isslop/{uid}/events?after=SEQ poll, so guests (who cannot subscribe to public.* unless pubsub_allow_guests is on) and reconnecting tabs replay from the durable trail. Never rely on pub/sub alone for this tool: the DB event trail is the source of truth, pub/sub is the fast path.
- Engine layout:
services/jobs/isslop/holdsacquisition/(git probe viagit 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), pluspipeline.py(the event-yielding run),worker.py(subprocess entry),events.py(frame protocol),persistence.py(EventPersisterwrites 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.processwrites the worker payload (url + admin toggles + gateway endpoint/model/key) toconfig.ISSLOP_RUNS_DIR/{uid}/payload.json, resolves the workspace underconfig.ISSLOP_WORKSPACES_DIR(workspace_forrejects any path escaping the root), launchespython -m devplacepy.services.jobs.isslop.worker <payload> <workspace>, and relays each NDJSON stdout line throughEventPersister.apply(SQLite) thenpubsub.publish. The workspace and run dir are removed in afinally; 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.pytalks solely toconfig.INTERNAL_GATEWAY_URLwith modelmolodetzanddatabase.internal_gateway_key()(vision uses the same model - the gateway handles image parts).review_available/vision_availablegate 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 throughstealth_async_client. - Artifacts are permanent, the job row is not. Like
ForkService,cleanup()never touchesisslop_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 thejobstracking row.devplace isslop clearis the only bulk hard-delete (plus per-analysisstore.purge_analysis). - Ownership and guest history sync: the owner is
("user", uid)or("guest", DEVII_GUEST_COOKIE)- NOT the tools_shared.owner_forIP 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_historyruns on page and list requests: when a signed-in user still carries a guest cookie,store.claim_guest_analysesre-owns those rows via UPDATE (a move, never a copy - no duplicate data). One active analysis per owner (429otherwise, auditeddenied). - Tables:
isslop_analysesis soft-deletable (inSOFT_DELETE_TABLES, born-live inserts, reads filterdeleted_at IS NULL, indexed on(owner_kind, owner_id, created_at)/status/content_hash); the evidence tables (isslop_eventskeyed(analysis_uid, seq),isslop_file_results,isslop_image_results,isslop_reportsUNIQUE onanalysis_uid) are GC-only evidence purged with their analysis. All ensured ininit_db(). - Routes (all under
/tools/isslop, capability URLs):GET ""page,POST /run(IsslopRunForm, http/git/ssh URL pattern),GET /list(owner history),GET /{uid}(IsslopAnalysisOut),GET /{uid}/events(ordered replay),GET /{uid}/report(respond(..., IsslopReportOut)- HTML shows the live<dp-isslop-run>while running and the server-rendered report when completed; the markdown body goes throughrender_content),GET /{uid}/report.md,GET /{uid}/badge.svg(self-contained SVG, hardcoded colors by design - it must render on external sites). Badge/report URLs are absolute viaseo.site_url. - Frontend: two site-wide web components (
static/js/components/AppIsslop.js<dp-isslop>= submit form + history list;AppIsslopRun.js<dp-isslop-run>= live progress feed), registered incomponents/index.js, light DOM, reusingHttp/Pollerandapp.pubsub. Page CSSstatic/css/isslop.css(design tokens). Ondonethe run component reloads the page so the report is the server-rendered (SEO/render_content) version, never a client re-render. - Verdict blending is per-file, never a global mean (load-bearing). The AI review pass runs its per-file gateway calls concurrently (
pipeline.AI_REVIEW_CONCURRENCY, semaphore-bounded like the image pass) and adjusts ONLY the files it actually reviewed:pipeline.apply_ai_verdictsblends each sampled file's static origin/quality with its own verdict (0.6/0.4), then the WHOLE repo is re-aggregated with the normal SLOC/criticality weights, andscoring.ai_fractionmaps per-file origin scores through a smooth 35-65 ramp (never the old hard 45/55 buckets). Never reintroduce a repo-level mean of the 12 sampled verdicts - it hands a tiny sample a fixed 40% of the verdict, so the LLM's clustered hedging values (30/40/50) drown thousands of files of static evidence and unrelated projects converge on identical percentages (the real-world twin-84%-human defect). Single source of truth for the final verdict: the SCORE event, the DONE payload andgenerate_reportall consume the SAME finalRepoScores(image influence applied viascoring.adjust_for_images, which recomputes slop/grade/human together) - the summary grade and the report body grade can therefore never disagree;tests/unit/services/jobs/isslop/pipeline.pyguards both invariants. - Image evidence thumbnails + retry-safe evidence. Workspaces die with the run, so the vision stage persists an aspect-preserving WebP thumbnail per reviewed image (
vision.make_thumbnail, sha1-of-relative-path name) intoconfig.ISSLOP_MEDIA_DIR/{uid}(the dir comes to the worker via the payloadmedia_dir); thethumbname rides theimageevent, is stored onisslop_image_results, and is served byGET /tools/isslop/{uid}/media/{name}(strict^[a-f0-9]{16}\.webp$name pattern +is_relative_toroot check - never loosen either). The report page renders the thumbnails withdata-lightbox(the sharedapp.lightboxopens them full-size) and the live feed shows a tiny inline preview per image event.store.reset_evidence(uid)runs at the top of everyIsslopService.process- a retried job (orphan recovery) previously re-inserted its events/file/image rows, duplicating every image and file in the report; any new evidence table MUST be added toreset_evidenceANDpurge_analysis. - Template provenance (the "ships defaults" detector).
analysis/templates.pydetect_template(workspace)scores starter-template evidence repo-wide (it reads files the inventory excludes, likepackage.json): known template slugs/authors in the manifest (+ct3aMetadata), README template marketing (weights capped so a wordy README cannot alone confirm), and the kitchen-sink scaffold constellation (count of standard scaffold artifacts past 3 freebies). The saturating score feedsscoring.adjust_for_template- a no-op below 35, a 0.7x floor on ai_percent/origin when confident, 0.85x when >= 70 - applied LAST in the pipeline scoring stage, after the AI and image blends. Near-certain evidence (>= 70) also FORCES categoryai-slop(defaults shipped as-is are slop by the canonical definition - clean scaffold code never earns an untouched templatesophisticated-ai, whose meaning is 'the presenter decided'); the confident band caps ahuman-*category atuncertain. Calibration truth set (guarded by tests): the six stock boilerplates (ixartz x2, create-t3-app, vercel ai-chatbot x2, fullstack-nextjs-app-template) grade C-D with markers listed, while devplacepy itself scores 0.0 with zero markers - tune weights against BOTH sides, never only the slop set. The evidence rides thesignalinventory event, the SCORE payload (template_score/template_markers) and the report's Template Provenance section; admin toggleisslop_template_detection. - Rendered-DOM signal family (
analysis/domsignals/). A homepage-only, live-browser companion to the text-basedsignals/webtells.pychecks:acquisition/browser.py'sStealthBrowser.capture()loads the page and returns the actual rendered DOM (computed styles, a class-name census, meta tags, headings/landmarks, console warnings, network response hosts/headers, a screenshot) viaDOM_EXTRACT_SCRIPTinacquisition/domcapture.py.domsignals/base.pydefines its own@dom_check/@dom_site_checkdecorator registry, mirroring the SEO job'schecks/@page_check/@site_checkmechanics, but emitting isslop's ownSignaltype (not a new one). Eight category modules (builders,color,typography,layout,copy,metaseo,accessibility,buildsignals) contribute 49 distinct signal codes;domsignals/aggregate.pyaggregate_dom_evidenceruns every registered check over the captured page(s) and saturates the weighted total into aDomEvidence(score/bucket/signals/detected_builder/builder_confidence). This brings the engine's total to twenty-one detector families (13 text-based insignals/, 8 rendered-DOM indomsignals/) and 126 signal codes (77 text-based, 49 rendered-DOM) - update these counts again the next time a family is added or removed, never leave them stale. - Homepage-only by design (
config.DOM_ANALYSIS_MAX_PAGES = 1). Capturing a full DOM, console, network trail and screenshot on every crawled page would multiply the browser cost of a run, so the pass runs ONLY against the first page at crawl depth 0 (acquisition/website.pygatesdepth == 0 and index < DOM_ANALYSIS_MAX_PAGES). Bump the constant later if the tool needs multi-page DOM coverage -DomSiteContext/dom_site_check(e.g.detect_duplicate_meta_description) already support more than one page. Git-repository sources never populatedom_snapshots(dom_sinkis threaded only throughcrawl_website, neverclone_repository), and a run where the browser could not be launched simply yields an empty page list, soaggregate_dom_evidence([])is a clean no-op (DomEvidence(score=0.0, bucket="none")). - Scoring order is load-bearing: images -> DOM -> template, never reorder.
pipeline.pyappliesscoring.adjust_for_images, thenadjust_for_dom_signals, thenadjust_for_template, in that exact sequence, andadjust_for_templateMUST stay last.adjust_for_dom_signalsblendsDomEvidence.scoreintoai_percentat a small, deliberately cautious weight (config.DOM_AI_WEIGHT = 0.12, since this whole signal family is new and uncalibrated) UNLESS a confident builder match (builder_confidence >= DOM_BUILDER_CONFIDENT_THRESHOLD) forces the category toai-slopvia the same_force_slop_categoryhelper the template detector uses. Becauseadjust_for_templateruns after it, a template match can still floor/force the category further; nothing may run afteradjust_for_template, since a later step would silently undo a forcedai-slopcategory. - DOM evidence never attaches to a per-file
FileScore/FileContext(do not bolt it on).DomEvidenceis repo/page-level evidence blended once into the finalRepoScores, exactly like template provenance and the image mean - never distributed across individual files. A rendered page has no SLOC and no line numbers to weight against, and its evidence (computed styles, a screenshot, console/network output) is not textual, so it cannot be scored, sampled or displayed through the SLOC-weighted per-file model the rest of the engine uses. Any new DOM check emits page-levelSignals intoDomEvidence.signals, never into aFileScore.signalslist. isslop_dom_resultsfollows the same evidence-table obligation as every other isslop table. It mirrorsisslop_image_results(store.pyTABLE_DOM_RESULTS) and is already wired into bothstore.reset_evidence(uid)andstore.purge_analysis(uid); any future evidence table added underdomsignals/must be added to both the same way.- DEP_UNRESOLVED is alias-aware (do not regress). The JS/TS unresolved-import detector (
signals/hallucination.py) flags imports that match no package.json dependency (all four sections), Node builtin or local path - i.e. phantom/hallucinated dependencies. It MUST skip everything that is not an npm specifier: relative/absolute/URL imports,node:and anyscheme:specifier, the non-package prefixes@/,~,#,$(tsconfig/subpath/Svelte aliases - none are valid npm names), and every prefix parsed fromtsconfig.json/jsconfig.jsoncompilerOptions.paths(engine._javascript_alias_prefixes, carried onRepoContext.javascript_alias_prefixes). tsconfig is JSONC and alias keys contain/*, so comments are stripped with the string-aware scanner_strip_jsonc_comments- NEVER a comment regex (a regex eats the"@/*"alias strings themselves; this was a real defect). Before the alias awareness the detector flagged nearly every file of a standard Next.js app; after, only genuine phantom deps remain.tests/unit/services/jobs/isslop/hallucination.pyguards it. - Clickable source references (annotated source viewer). The static stage persists the FULL source of every signal-bearing file (cap
SOURCE_CAP_FILES=60 files / 200KB each) into the same per-analysis media dir (_persist_source,s<sha1>.txt); the name rides thefileevent and theisslop_file_results.sourcecolumn.GET /tools/isslop/{uid}/source?path=...&line=Nrendersisslop_source.html: server-rendered line table (Jinja autoescape covers XSS) with line-number anchors#LN, signal lines highlighted with inline annotation chips, a focused line, and a findings nav in the sidebar; the strict^s[a-f0-9]{16}\.txt$+is_relative_tochecks mirror the media route. Everything referencing a file links there: the file-results table path, each signal chip (&line=N#LN), and the report prose -_linkify_sourcesrewrites backticked paths in the report markdown into links BEFORErender_content, and the reporter system prompt requires the model to backtick every path it mentions.reset_evidence/purge_analysisalready sweep the media dir, so sources share the thumbnail lifecycle. - No absolute paths ever leave the engine: every user-facing path is workspace-relative (
relative_to(workspace)); keep it that way in new detectors/events. - Docs heading anchors +
.docs-toc(platform-wide):docs_prose._render_markdownpost-processes every prose page, stamping a slugifiedidon eachh2/h3(heading_slug, GFM-style, deduplicated with-Nsuffixes) and appending a hover-visible.docs-heading-anchorpermalink;scroll-margin-topkeeps targets below the topnav. Theisslop-checkspage uses this for its clickable Contents grid: a.docs-tocnav placed OUTSIDE thedata-renderblock (raw HTML passes through untouched) whosehref="#slug"values are computed with the SAMEheading_slugfunction at generation time - reuse.docs-toc/.docs-toc-item/.docs-toc-count(styled indocs.css) for any other long docs page, and never hand-write a slug thatheading_slugwould not produce.tests/unit/docs_prose.pyguards the slugging and injection. - Devii tools
isslop/isslop_status/isslop_report/isslop_list(member-only,requires_auth=Trueper policy - the HTTP surface stays public); docstools-isslop+isslop-checks(the full plain-language check catalog); CLIdevplace isslop analyze|prune|clear; auditisslop.run.request|complete|failed(categorytools); achievement keyisslop("Slop Hunter"). New unpinned depplaywright-stealth; runtime dirsconfig.ISSLOP_DIR/ISSLOP_WORKSPACES_DIR/ISSLOP_RUNS_DIRinDATA_PATHS.