/** * LLM-driven document section classifier via Claude Haiku. * * Given the full text of an unstructured document and the loaded ontology * label set, returns a typed-section structure that `memory-ingest` * consumes to write typed graph nodes with natural anchor edges. * * Trust boundary: the document text comes from an external file the * operator uploaded. The system prompt sandboxes it as classification * input — any imperative verbs inside it are data, not instructions. * Mirrors the pattern in llm-ranker.ts. * * Auth: runs on Claude Code OAuth via `callOauthLlm`, never * the Anthropic API key. The API-key path is reserved for the public * agent. * * Hallucination defence: section `kind` is enforced as a closed enum in * `ClassifierOutputTool.input_schema`, so a hallucinated kind cannot reach * this module — Anthropic rejects the tool_use input at the API boundary. * For `related[].kind` and `documentEdges[].targetKind` (which the static * schema cannot constrain because the live ontology label set is a runtime * input), the post-validator drops any label not in `ontologyLabels`. * Failure of the LLM call (missing creds, network, schema-violating * `tool_use.input`) returns `{kind: "error", reason}`. The caller decides * whether to abort the ingest or degrade-on-error per session; classifier * never silently substitutes a degraded write. */ import { type OauthLlmTool } from "../../../../../lib/oauth-llm/dist/index.js"; /** Direction of the anchor edge relative to the typed node. */ export type AnchorEdgeDirection = "from-anchor" | "to-anchor"; /** Direction of a related-node edge relative to the typed node. */ export type RelatedEdgeDirection = "outgoing" | "incoming"; /** A related entity the typed node connects to (e.g. Position → Organization). */ export interface ClassifiedRelated { /** Ontology label — verified against the loaded label set. */ kind: string; /** Properties on the related node. */ properties: Record; /** Edge from the typed node to the related node. */ edge: { type: string; direction: RelatedEdgeDirection; properties?: Record; }; /** * When true, MERGE the related node on its identifying property * (e.g. Organization on `name`); when false, CREATE. * Defaults to true — entity reuse is the safer default. */ merge?: boolean; } /** A single classified section of the document (every section is one `:Section` node). */ export interface ClassifiedSection { /** * Section kind from the closed enumeration declared in schema-base.md. * Becomes a secondary label on the `:Section` node (e.g. `:Section:Position`). * * Identity kinds (anchor edges to UserProfile/LocalBusiness): * Position, Education, Credential, Skill, Biography * Document-structural kinds (HAS_SECTION + NEXT only): * Preface, Abstract, Introduction, TableOfContents, Chapter, * Conclusion, Appendix, Bibliography, Glossary, Acknowledgments * Contract-clause kinds (HAS_SECTION + NEXT, with special-case extras for Parties + Definitions): * Parties, Recitals, Definitions, Scope, Term, Payment, Confidentiality, * IntellectualProperty, Warranties, Indemnification, Liability, * Termination, GoverningLaw, ForceMajeure, Notices, EntireAgreement, * Amendment, Assignment, Severability, Signatures * Label fallback: * Other (carries a `classifierReason` property naming what the classifier * thought the section was about; ontology-growth signal) * * Standalone non-section node kind that may appear here: * Project (written as a separate `:Project` node, anchored via CREATED) */ kind: string; /** Short human-readable title for the section. */ title: string; /** * The section's body text — embedded and stored on the section node. * * server-reconstructed via `documentText.slice(sourceStart, sourceEnd)`. * The LLM emits offsets, never the body text — output size becomes O(sections), * not O(input chars). Callers consume the same `body: string` shape as before. */ body: string; /** * 1-3 sentence summary of the section, ≤500 chars (server-validated). * The LLM emits this; the server truncates if oversize. Stored as * `properties.summary` on the section node so adjacency search can * surface it without rehydrating the body. */ summary: string; /** * Whole-document character offsets — inclusive start, exclusive end. * The LLM emits these; the server validates bounds and reconstructs * `body` via `documentText.slice(sourceStart, sourceEnd)`. In the * chunked-classify path these are translated from chunk-local to * whole-document coordinates so the merge step can detect boundary * straddlers across chunks. */ sourceStart: number; sourceEnd: number; /** Properties on the section node (excluding accountId/embedding/provenance). */ properties: Record; /** * Edge from the document subject (anchor) to / from the section node. * Null for structural and contract-clause kinds — they anchor only via * the implicit `(:KnowledgeDocument)-[:HAS_SECTION]->(:Section)` + `:NEXT` chain. */ anchorEdge: { type: string; direction: AnchorEdgeDirection; properties?: Record; } | null; /** Other entities this section references (e.g. Position → Organization via AT). */ related?: ClassifiedRelated[]; /** * For `kind === "Other"`: one-line description of what the classifier thought * the section was about. Stamped on the `:Section:Other` node as the * `classifierReason` property so the ontology-growth review query * `MATCH (s:Section:Other) RETURN s.title, s.classifierReason, count(*)` * can surface candidates for new section-kind labels. */ classifierReason?: string; } /** * A node the classifier emitted but could not edge naturally. * The skill surfaces these to the operator loudly so they decide whether * the orphan is intentional (rare, valid) or a classifier miss (bug). * Writer never synthesises its own edges to "fix" orphans. */ export interface OrphanCandidate { /** Ontology label of the would-be node. */ kind: string; /** Short human-readable label naming the entity (e.g. organisation name). */ label: string; /** Why the classifier could not find a natural edge for this node. */ reason: string; } /** The classifier's full output. */ export interface ClassifierOutput { /** 1–3 sentence summary of the whole document. */ documentSummary: string; /** Topic keywords for the document (for retrieval and filing). */ documentKeywords: string[]; /** Per-section structure. */ sections: ClassifiedSection[]; /** Nodes the classifier could not edge naturally — operator decides per case. */ orphanCandidates: OrphanCandidate[]; /** Document-level edges the classifier proposes off the KnowledgeDocument * (e.g. PARTY edges to Person/Organization for a contract). The writer * applies these; never invents its own. */ documentEdges?: Array<{ type: string; direction: "outgoing" | "incoming"; targetKind: string; targetProperties: Record; /** Default true — MERGE Person/Organization on identifying property. */ merge?: boolean; }>; /** Number of related-node `kind` values dropped as hallucinations (diagnostic). */ hallucinatedRelated: number; } export type ClassifyResult = { kind: "ok"; output: ClassifierOutput; } | { kind: "error"; reason: string; }; /** * Closed enumeration of section `kind` values. Each becomes a secondary * label on the `:Section` node (e.g. `:Section:Position`). The `kind` field * is enforced as an `enum` in `ClassifierOutputTool.input_schema`, so a * hallucinated kind cannot reach the post-validator — Anthropic rejects the * tool_use input at the API boundary. * * Source of truth: schema-base.md "Section kinds" table. Changes here MUST * be mirrored there or the validator will reject the secondary label. */ export declare const SECTION_KIND_OTHER = "Other"; export declare const IDENTITY_SECTION_KINDS: readonly ["Position", "Education", "Credential", "Skill", "Biography"]; export declare const STRUCTURAL_SECTION_KINDS: readonly ["Preface", "Abstract", "Introduction", "TableOfContents", "Chapter", "Conclusion", "Appendix", "Bibliography", "Glossary", "Acknowledgments", "Conversation"]; export declare const CONTRACT_SECTION_KINDS: readonly ["Parties", "Recitals", "Definitions", "Scope", "Term", "Payment", "Confidentiality", "IntellectualProperty", "Warranties", "Indemnification", "Liability", "Termination", "GoverningLaw", "ForceMajeure", "Notices", "EntireAgreement", "Amendment", "Assignment", "Severability", "Signatures"]; /** Standalone (non-Section) node kind the classifier may emit per section. */ export declare const STANDALONE_NODE_KINDS: readonly ["Project"]; export declare const ALL_SECTION_KINDS: ReadonlySet; export declare const ClassifierOutputTool: OauthLlmTool; export interface ClassifyParams { /** Account scope, for log prefixing. */ accountId: string; /** * Classification mode. * 'document' (default) — unstructured document → typed `:Section` chunks. * 'chat' — one chat session → `:Section` chunks under a * :ConversationArchive parent (Task 397) keyed on * `conversationIdentity`. Bypasses anchor / * related / documentEdges / orphan logic; the only * legal `kind` is `Conversation`. * Defaults to 'document' so existing PDF/CV/web callers are unchanged. */ mode?: "document" | "chat"; /** * Anchor description — a short human sentence the classifier reads to * decide which ontology edges fit. Example: * "subject = UserProfile (the account owner); edges from UserProfile." * "subject = LocalBusiness (the operator's business); edges from LocalBusiness." * The skill writer composes this from the operator's confirmation step. * In chat mode this is included for log context only — the chat prompt * has no anchor logic. */ anchorDescription: string; /** * Ontology label set the graph supports — only these are legal `kind` * values. Live ∪ declared ∪ markdown labels passed in by the caller * so the classifier never needs filesystem access. In chat mode this is * effectively unused (only `Conversation` is legal) but accepted for * symmetry with document mode. */ ontologyLabels: ReadonlySet; /** * Natural-edge map — pasted into the prompt verbatim. The skill renders * this from the schema-base.md document-ingestion typed-node table so * the classifier sees the exact edges the validator accepts. In chat * mode this is omitted from the user message (the chat prompt has no * edge proposals). */ naturalEdgeMap: string; /** Full text of the document, or one chat session in turn-attributed form. */ documentText: string; } /** * Classify a document into typed sections via Haiku. * * Returns: * { kind: "ok", output } on success — every section's `kind` is in the * closed enumeration (identity / structural / contract-clause / Other). * Sections the classifier could not natural-edge appear in * `output.orphanCandidates`. The skill surfaces orphans loudly to * the operator. * { kind: "error", reason } when the LLM is unavailable, returns * malformed JSON, or hits an input-too-large guard. The caller * decides whether to abort the ingest entirely (document mode) or * degrade-on-error per session (chat mode). Classifier * never silently substitutes a degraded write. */ export declare function classifyDocument(params: ClassifyParams): Promise; //# sourceMappingURL=llm-classifier.d.ts.map