"""每周把知识库自己读一遍,并把结论变成能点的提案。 **替换的是什么。** 既有的 `support.kb_gap_digest` 每周告诉 IT「这些问题零命中」。 那句话是真的,但没法用:它说了语料有洞,没说该动哪一条,于是它每周制造的工作 是**开启一次调查**。这道 cron 产出的是具体的改动 —— 给哪一条补哪个词、哪两条 该合并、哪条关联指向了空处 —— 每一条都带理由和证据,人点一下就落地。 两个 cron 是互补的,不是重复的:缺口周报回答「该写哪篇**新**文档」(它管的是 语料里根本没有的东西),这道 lint 回答「已有的这些**哪里不对**」。 失败处理遵循本仓约定:**handler 必须 raise**。但要小心区分 ——「跑完了但一条 提案都没产出」**不是失败**,那多半意味着语料是干净的;真正的失败是「这一轮压根 没跑成」(模型不可用、语料读不出来)。 """ from __future__ import annotations import logging from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.scheduled_jobs.registry import register_scheduled_job logger = logging.getLogger(__name__) @register_scheduled_job( key="knowledge.loop_lint", display_name="知识库 · 每周自检与回填", description=( "把已发布语料两两比一遍(重复 / 冲突 / 该互相关联)、把零命中的员工问句" "回接到本该回答它的条目上得出该补的说法、清理指向已下架条目的关联," "并把员工点过「有帮助」的助手回答提回语料。全部产出为待人工确认的提案," "不自动改动任何已发布内容。" ), # 语料两两比对 + 每对一次模型调用 + 缺口逐条回接 —— 分钟级,占着和员工 # 同一个模型后端。归重。 weight="heavy", ) async def run_knowledge_loop_lint( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.auditor.security_faq_wiki import ( lint_security_vocabulary, ) from app.domains.support.copilot import copilot_available from app.domains.support.kb_lint import backfill_from_answers, run_lint cfg = config or {} window_days = _clamp(cfg.get("window_days"), 14, 1, 90) summarise_limit = _clamp(cfg.get("summarise_limit"), 25, 0, 100) backfill_limit = _clamp(cfg.get("backfill_limit"), 10, 0, 50) if not await copilot_available(db): # 没配模型不是这道任务的错。但它也不能报成 ok —— 这套东西的每一步都 # 要模型确认,静默地什么都不做和「语料很干净」看起来一模一样,而那正是 # 「配置从来没生效过」能藏一整个季度的地方。 return { "status": "skipped", "reason": "assistant/LLM backend not configured", } report = await run_lint( db, window_days=window_days, summarise_limit=summarise_limit, ) await db.commit() backfill = ( await backfill_from_answers(db, window_days=window_days, limit=backfill_limit) if backfill_limit else {"considered": 0, "outcomes": {}} ) await db.commit() # The security answer library is a second corpus on the same queue, and its # diagnosed defect is the same one: the entries are written in customer- # questionnaire register and employees ask in their own words. Its input is # the security team's own gap ledger — questions people really asked and # did not get answered — so the pass proposes the phrasing that would have # found the entry, and proposes NOTHING when the library genuinely has no # position (that stays in the ledger, which is where the security team # already looks and the only place qualified to decide the company needs a # position it does not have). security = await lint_security_vocabulary(db) await db.commit() if not report.get("llm_available", True): # 中途模型不可用:已经产出的提案是真的,但这一轮没跑完,而且下一轮会 # 从头再来。报成失败让它变红,比记一条「ok, 3 条」更诚实。 raise RuntimeError( "knowledge.loop_lint: 模型在中途变得不可用,这一轮没有跑完。" f"已产出 {report.get('proposals', 0)} 条提案。" ) return { "status": "ok", **report, "backfill": backfill, "security_faq": security, } def _clamp(raw: Any, default: int, low: int, high: int) -> int: try: value = int(raw) except (TypeError, ValueError): return default return max(low, min(value, high))