"""Vocabulary of the knowledge loop. The loop is what turns a pile of documents into something that gets *better* when it is used. Today every source of new knowledge — a resolved ticket, a service-desk FAQ export, a Feishu wiki page — produces one thing: a brand-new draft article. Nothing ever updates an existing page, merges two pages that say the same thing, adds the word an employee actually used, or notices that two pages disagree. So the corpus grows and its recall does not: production writes 「电脑」 and never 「笔记本」, and the fix for that is not a better ranker, it is somebody adding an alias. Every machine-produced change lands here as a **proposal**, and a person decides it. That is deliberate and not a transitional stage: an assistant that edits the knowledge base it answers from, unsupervised, is a system whose errors are self-reinforcing. """ from __future__ import annotations # -------------------------------------------------------------------------- # Corpora # -------------------------------------------------------------------------- # # One queue serves several bodies of knowledge. They are named here rather than # inferred from the target id so a proposal is readable without a join, and so # the review UI can filter without knowing what a ``target_ref`` points at. CORPUS_SUPPORT_KB = "support_kb" #: Wired in a later change — the security answer library is a different corpus #: with a different voice (customer-questionnaire phrasing), and merging the two #: would be wrong. It reuses this queue, not this content. CORPUS_SECURITY_FAQ = "security_faq" CORPORA = (CORPUS_SUPPORT_KB, CORPUS_SECURITY_FAQ) # -------------------------------------------------------------------------- # Kinds # -------------------------------------------------------------------------- #: A genuinely new page. This is the only kind today's ingest can produce, and #: after this change it is the *last* resort rather than the default. KIND_NEW_ARTICLE = "new_article" #: Add alternate phrasings to an existing page. The cheapest and highest-yield #: kind: the known recall ceiling on this corpus is vocabulary, not ranking. KIND_ADD_ALIASES = "add_aliases" #: Append a section to an existing page — new knowledge about something already #: written down, which today becomes a second page that competes with the first. KIND_APPEND_SECTION = "append_section" #: Two pages say the same thing. Accepting keeps one and retires the other, #: leaving a link behind so the retired page's history stays reachable. KIND_MERGE = "merge" #: Two pages disagree. Accepting does NOT pick a winner — nothing here is #: qualified to. It records the contradiction as a link so the pair shows up #: together for whoever owns the policy. KIND_CONTRADICTION = "contradiction" #: A page points at something that no longer exists (an archived mirror page, a #: retired sibling). KIND_DEAD_LINK = "dead_link" #: Relate two pages that are about the same job without duplicating it. KIND_LINK = "link" #: Retire a page. Only ever proposed, never applied automatically. KIND_RETIRE = "retire" KINDS = ( KIND_NEW_ARTICLE, KIND_ADD_ALIASES, KIND_APPEND_SECTION, KIND_MERGE, KIND_CONTRADICTION, KIND_DEAD_LINK, KIND_LINK, KIND_RETIRE, ) #: Kinds that change an existing page rather than adding one. The console groups #: on this because the reviews are different jobs: judging a new page is writing #: review, judging an edit is "is this still the same page". KINDS_MUTATING = ( KIND_ADD_ALIASES, KIND_APPEND_SECTION, KIND_MERGE, KIND_RETIRE, ) #: Kinds a BULK accept may apply, and the rule is an asymmetry: adding a word to #: a page cannot make the page wrong, while archiving one can, and splicing text #: into a live page can. Bulk exists so fifteen alias suggestions are one click; #: it must not also be a way to archive fifteen pages, or to publish fifteen #: paragraphs of someone else's assistant answer, without reading them. #: #: ⚠️ Enforced SERVER-side. The console has had this list since the queue #: shipped, and the endpoint had no idea — so the rule lived entirely in the #: layer that cannot enforce it, one API call away from not applying. The #: read-only guard next to it (``_guard_editable``) shows the intended pattern. #: Which payload keys a reviewer may fill in when accepting, per kind. #: #: ⚠️ "Any key the proposal already declares" was NOT a sufficient constraint, #: and the docstring that claimed it was is the reason this table exists. For #: ``dead_link`` the declared key ``link_id`` **is the target**: the adapter #: resolves it straight to a row and deletes it, so a reviewer could name any #: link in the corpus while the decision audit named the legitimately-dead one. #: The override is for filling in a blank the producer deliberately left, and #: today there is exactly one of those — the body of a ``new_article`` raised #: from an evaluation failure, which is filed empty because a generated answer #: is the confident, unsupported prose this queue exists to keep out. OVERRIDABLE_PAYLOAD_KEYS: dict[str, frozenset[str]] = { KIND_NEW_ARTICLE: frozenset({"title", "body", "category"}), } KINDS_BULK_SAFE = ( KIND_ADD_ALIASES, KIND_LINK, ) # -------------------------------------------------------------------------- # Sources # -------------------------------------------------------------------------- SOURCE_INGEST_TICKET = "ingest_ticket" SOURCE_INGEST_FAQ = "ingest_faq" SOURCE_INGEST_MIRROR = "ingest_mirror" SOURCE_LINT = "lint" SOURCE_ANSWER_BACKFILL = "answer_backfill" SOURCE_EVAL_FAILURE = "eval_failure" SOURCE_MANUAL = "manual" SOURCES = ( SOURCE_INGEST_TICKET, SOURCE_INGEST_FAQ, SOURCE_INGEST_MIRROR, SOURCE_LINT, SOURCE_ANSWER_BACKFILL, SOURCE_EVAL_FAILURE, SOURCE_MANUAL, ) #: Sources whose proposals must be READ before they are accepted, whatever #: their kind. #: #: The bulk rule is about the KIND — "adding a word to a page cannot make the #: page wrong" — and that reasoning holds only while the word came from the #: corpus itself. These three carry text derived from ONE employee's own #: conversation or ticket, and `search_aliases` is served verbatim to every #: employee by ``GET /support/kb``. The scrub applied on the way in removes the #: machine-readable half (e-mail, phone, uuid, tokens) and cannot remove a #: colleague's name, a device serial or an employee number — so a bulk accept #: here publishes company-wide something nobody read. SOURCES_NEEDING_A_READ = ( SOURCE_ANSWER_BACKFILL, SOURCE_INGEST_TICKET, SOURCE_EVAL_FAILURE, ) # -------------------------------------------------------------------------- # Status # -------------------------------------------------------------------------- STATUS_OPEN = "open" STATUS_ACCEPTED = "accepted" STATUS_REJECTED = "rejected" #: The corpus moved under the proposal — the page it targets was edited, #: retired or deleted after the proposal was written. Not a rejection: nobody #: decided anything, the question stopped being askable. STATUS_SUPERSEDED = "superseded" #: Accepted, but applying it failed. Kept distinct from ``open`` so a retry is a #: deliberate act and the failure is visible rather than looking like a queue #: that never drains. STATUS_FAILED = "failed" STATUSES = ( STATUS_OPEN, STATUS_ACCEPTED, STATUS_REJECTED, STATUS_SUPERSEDED, STATUS_FAILED, ) TERMINAL_STATUSES = (STATUS_ACCEPTED, STATUS_REJECTED, STATUS_SUPERSEDED) # -------------------------------------------------------------------------- # Link kinds (corpus-side, but named here so both halves agree) # -------------------------------------------------------------------------- LINK_RELATED = "related" LINK_SUPERSEDES = "supersedes" LINK_CONTRADICTS = "contradicts" LINK_DERIVED_FROM = "derived_from" LINK_KINDS = (LINK_RELATED, LINK_SUPERSEDES, LINK_CONTRADICTS, LINK_DERIVED_FROM) #: Link kinds that are symmetric in meaning. Stored once, in a canonical #: direction (lower id first), because storing both directions means two rows #: that can disagree and a reader who has to remember to query both. LINK_KINDS_SYMMETRIC = (LINK_RELATED, LINK_CONTRADICTS) # -------------------------------------------------------------------------- # Bounds # -------------------------------------------------------------------------- #: Index entries handed to the model when it is asked "is this already written #: down?". The whole index would be cheaper to reason about but does not fit, #: and a shortlist that is too long buries the real match. MAX_CANDIDATES = 8 #: An ingest run's ceiling on proposals. A misconfigured importer that turns a #: 3,000-row spreadsheet into 3,000 proposals produces a queue nobody opens. MAX_PROPOSALS_PER_INGEST = 50 #: Summary length. Long enough to tell two similar pages apart in a list, short #: enough that a hundred of them fit in one prompt. SUMMARY_MAX_CHARS = 180 __all__ = [name for name in dir() if not name.startswith("_")]