|
| 1 | +# Suitability QA — Design Summary |
| 2 | + |
| 3 | +> **Abacus.Run.Service · Workflow design summary** |
| 4 | +
|
| 5 | +A suitability report asserts facts about a client. The supporting case file either corroborates those |
| 6 | +facts or it doesn't. This workflow runs ten regulatory checks across that gap and returns findings |
| 7 | +anchored to the exact lines of the report that make each claim. |
| 8 | + |
| 9 | +| | | |
| 10 | +| --- | --- | |
| 11 | +| Host | Microsoft Agent Framework via Abacus.Run | |
| 12 | +| Checks | 10 | |
| 13 | +| Categories | A–I | |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Deployables — two services, one boundary |
| 18 | + |
| 19 | +Reference data and results outlive any particular workflow. They are owned by a separate service, so |
| 20 | +a revised check prompt or a corrected finding does not require redeploying the thing that runs the |
| 21 | +checks — and the workflow host keeps only what it alone knows. |
| 22 | + |
| 23 | +### `Abacus.Data.Service` — system of record |
| 24 | + |
| 25 | +Owns the catalog — document categories and assessment checks, managed by API and seeded from the |
| 26 | +shipped CSVs — and the assessment tree of findings, anchors and evidence. Renders the report from |
| 27 | +that data on demand. This is what reviewers and downstream systems read. |
| 28 | + |
| 29 | +### `Abacus.Run.Service` — workflow host and audit trail |
| 30 | + |
| 31 | +Runs the MAF workflow: ingest, retrieve, judge. Reads the catalog over HTTP and publishes each |
| 32 | +finding back. It produces the data a report is made of; it does not render one. Its own store holds |
| 33 | +the ingestion ledger, and its audit record goes to the framework's generic audit store. |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +## The shape of the problem — two corpora, deliberately kept apart |
| 38 | + |
| 39 | +Everything downstream follows from one distinction: the suitability report is the thing being |
| 40 | +audited, and the rest of the case file is what it's audited against. Conflating them would let the |
| 41 | +report corroborate itself, so the separation is enforced structurally rather than by convention — the |
| 42 | +report's category is never ingested, and never offered to retrieval. |
| 43 | + |
| 44 | +### Source of facts — the suitability report, category I |
| 45 | + |
| 46 | +Located in the excluded-category folder, parsed to a line-numbered document with a page map. Never |
| 47 | +embedded, never searched. Passages are attributed to checks by keyword so each check sees the part of |
| 48 | +the report it's about, not the whole file. |
| 49 | + |
| 50 | +### Source of evidence — the supporting case file, categories A–H |
| 51 | + |
| 52 | +Fact finds, meeting notes, risk profiles, policy information, cashflow modelling, research, |
| 53 | +illustrations. Converted, chunked and embedded with case and category metadata, then retrieved per |
| 54 | +check under a mandatory case filter. |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +## Composition — the workflow graph |
| 59 | + |
| 60 | +Ingestion and parsing run once per case. The ten checks then fan out as independent nodes and rejoin |
| 61 | +at a barrier, and the terminal node settles the assessment in the data service. Nothing here renders |
| 62 | +a report — that is the data service's job, on request. |
| 63 | + |
| 64 | +```mermaid |
| 65 | +flowchart LR |
| 66 | + ingest["ingest-supporting"] --> parse["parse-suitability"] |
| 67 | + parse --> open["open-assessment"] |
| 68 | +
|
| 69 | + open -->|fan-out| c1["check:CHK-001"] |
| 70 | + open -->|fan-out| c2["check:CHK-002"] |
| 71 | + open -->|fan-out| cn["check:CHK-###"] |
| 72 | + open -->|fan-out| c10["check:CHK-010"] |
| 73 | +
|
| 74 | + c1 -->|fan-in barrier| agg["aggregate-outcomes"] |
| 75 | + c2 -->|fan-in barrier| agg |
| 76 | + cn -->|fan-in barrier| agg |
| 77 | + c10 -->|fan-in barrier| agg |
| 78 | +
|
| 79 | + agg --> complete["complete-assessment"] |
| 80 | +``` |
| 81 | + |
| 82 | +Fan-out broadcasts the same assessment state to every check; the fan-in barrier holds until all ten |
| 83 | +emit. |
| 84 | + |
| 85 | +Each check is its own node with its own executor id, rather than a loop inside one node. That buys |
| 86 | +three things: the runtime can execute them in parallel, a single check can fail or retry without |
| 87 | +disturbing the other nine, and tenants can gate an individual check through the existing |
| 88 | +gate-configuration API without any change to this graph. |
| 89 | + |
| 90 | +The node set is built from the catalog at `BuildAsync` time — one binding per check row, with the |
| 91 | +barrier's expected count taken from that same list — so adding an eleventh check is a catalog edit, |
| 92 | +not a code change. |
| 93 | + |
| 94 | +--- |
| 95 | + |
| 96 | +## Per-check anatomy — what each check actually does |
| 97 | + |
| 98 | +A check is not one prompt. It is a reasoning step that decides what evidence would settle the |
| 99 | +question, a deterministic retrieval step that goes and gets it under filters the model cannot |
| 100 | +influence, and a judgement step over what came back. |
| 101 | + |
| 102 | +### 1. Plan — LLM reasoning |
| 103 | + |
| 104 | +The model receives the check's prompt, what-to-look-for, decision logic and regulatory basis, the |
| 105 | +suitability report's own assertions for that check, and the document-category catalog. It returns an |
| 106 | +`EvidencePlan`: a few targeted queries, each tagged with the categories that check implies, plus what |
| 107 | +each query is seeking. |
| 108 | + |
| 109 | +The alternative — one fixed query built from the check's title — asks the corpus a question nobody |
| 110 | +wrote. Letting the model derive "what would corroborate this" first is the difference between |
| 111 | +searching for the check and searching for the evidence the check needs. |
| 112 | + |
| 113 | +### 2. Search — deterministic and filtered |
| 114 | + |
| 115 | +Each planned query runs against the vector store. Planning is advisory; filtering is not. |
| 116 | + |
| 117 | +> **`case_reference` — mandatory by construction** |
| 118 | +> |
| 119 | +> `EvidenceQuery` throws if built without a case reference, category codes, or query text. There is |
| 120 | +> no "search everything" fallback to regress into. The retriever applies the case reference and the |
| 121 | +> model is never shown it, so it cannot widen or forget the scope. |
| 122 | +
|
| 123 | +> **`document_category` — validated, not trusted** |
| 124 | +> |
| 125 | +> Codes the model proposes are checked against the seeded catalog and dropped if unknown. The catalog |
| 126 | +> offered to the planner already excludes the suitability report's own category, so `I` cannot be |
| 127 | +> chosen. If nothing valid survives, it falls back to the check's catalogued categories. |
| 128 | +
|
| 129 | +### 3. Merge |
| 130 | + |
| 131 | +Hits are de-duplicated across queries by `(source, page, snippet)`, keeping the best score, then |
| 132 | +ranked and capped. The verdict call sees the strongest targeted evidence rather than everything the |
| 133 | +corpus could return. |
| 134 | + |
| 135 | +The prompt also lists the searches that ran and what each sought, so the model can reason about a gap |
| 136 | +as "I looked in these categories and found nothing" rather than inferring it from absence. |
| 137 | + |
| 138 | +### 4. Judge and persist |
| 139 | + |
| 140 | +A structured `CheckVerdict` comes back as a decision, a rationale, the report lines it anchors to, |
| 141 | +and the evidence it cites. It is written as one finding per check per assessment, upserted on |
| 142 | +`(AssessmentId, CheckId)` so a re-run replaces cleanly instead of accumulating. |
| 143 | + |
| 144 | +Decisions are `NoIssue`, `PotentialConcern`, or `NotApplicable`. |
| 145 | + |
| 146 | +--- |
| 147 | + |
| 148 | +## Auditing — the record is declared, not hard-coded |
| 149 | + |
| 150 | +A regulated assessment has to be defensible months later: not just what was concluded, but what the |
| 151 | +model was asked, what it was shown, and what it actually said. That record belongs to the workflow |
| 152 | +that produced it — so the framework supplies the hook and the storage, and the workflow definition |
| 153 | +declares the shape. |
| 154 | + |
| 155 | +### 1. The definition declares its record |
| 156 | + |
| 157 | +`SuitabilityQaWorkflowDefinition` implements `IAuditedWorkflowDefinition` and returns an |
| 158 | +`AuditRecordDefinition`: a root kind — the assessment instance — plus the child constructs it may |
| 159 | +contain. For this workflow that is the submission, the ingestion summary, the parsed source document, |
| 160 | +and per check the retrieval plan, the searches executed, the input, the output, and the publication |
| 161 | +result. |
| 162 | + |
| 163 | +The declaration is the contract. A section the definition never declared is refused rather than |
| 164 | +silently stored, and the shape is discoverable at `GET /qa/audit/definition`. |
| 165 | + |
| 166 | +### 2. The runtime hands every node a recorder |
| 167 | + |
| 168 | +When a definition declares a record, the runner builds an `IWorkflowAuditRecorder` bound to it and |
| 169 | +attaches it to each node's `Runtime.Audit`, alongside the gate evaluator and middleware pipeline it |
| 170 | +already carried. A definition that declares nothing gets `null` and pays nothing. |
| 171 | + |
| 172 | +Executors then write as they go: the first node opens the root so a run that dies during ingestion |
| 173 | +still leaves a record of what it was asked to do, and the last closes it. |
| 174 | + |
| 175 | +### 3. Storage stays generic |
| 176 | + |
| 177 | +`IAuditRecordStore` holds a root and a stream of entries typed by string with JSON payloads. A new |
| 178 | +workflow declares new sections and needs no schema change. The framework ships an in-memory |
| 179 | +implementation; the host replaces it with SQLite. |
| 180 | + |
| 181 | +Entries are keyed by `(instance, kind, key)`, so a retried executor corrects its record rather than |
| 182 | +appending a contradictory second one. |
| 183 | + |
| 184 | +### 4. The record reads back through the framework |
| 185 | + |
| 186 | +`GET /workflows/{name}/instances/{id}/state` returns an instance's lifecycle status together with |
| 187 | +whatever record its workflow declared. The route holds no workflow-specific knowledge: the |
| 188 | +declaration drives the response, so every declared section appears — the empty ones included, which |
| 189 | +is what makes a run in progress readable. A workflow that declares nothing returns `audit: null`. |
| 190 | + |
| 191 | +The workflow name in the path is part of the identity, so reading an instance through another |
| 192 | +workflow's route is a 404 rather than a different view of the same record. Records grow large, so |
| 193 | +`?section=` narrows the response to named sections without changing its shape. The QA-specific |
| 194 | +`/qa/runs/…` endpoints remain for the presentation an investigator wants — the per-check exchange, |
| 195 | +the case index — and read the same store. |
| 196 | + |
| 197 | +One rule governs the whole mechanism: **an audit write must never fail the work it describes.** |
| 198 | +Storage errors are logged and swallowed — filing the explanation is not worth losing the result. The |
| 199 | +one place ordering matters is publication: the failure is recorded before it propagates, because a |
| 200 | +finding that could not be published is exactly what an investigator needs to find. |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## Components — what each part is responsible for |
| 205 | + |
| 206 | +| Component | Responsibility | Why this way | |
| 207 | +| --- | --- | --- | |
| 208 | +| **Entry point** | `POST /workflows/suitability-qa/instances` accepts the `QAContext` — a document path and a case reference. Results are read from the data service; `GET /workflows/suitability-qa/instances/{id}/state` returns the run's status with its audit record, and `/qa/runs/…` presents the same record the way an investigation reads it. | Submission rides the framework's existing instance API, so the workflow inherits status, retry and gate handling rather than reimplementing them. | |
| 209 | +| **Catalog** | Nine document categories and ten checks, owned by the data service. Managed through `/catalog/checks` and `/catalog/categories`, seeded from the two shipped CSVs on startup or on demand via `POST /catalog/seed`. | Checks are content, not code. Seeding is idempotent and keyed by `Code` / `CheckId`, so a revised prompt lands by editing the catalog — nothing is redeployed and no workflow restart is needed. | |
| 210 | +| **Catalog guards** | A check cannot reference an unknown category; a category in use cannot be deleted; a check with findings cannot be deleted. | The failure these prevent is silent: a check pointed at a category that does not exist retrieves nothing and reports a gap that looks like missing evidence rather than a bad edit. | |
| 211 | +| **Ingestion** | Scan category folders → convert → chunk → embed → record. PdfPig, OpenXML, ClosedXML, Tesseract OCR, with an optional Docling sidecar per format. | Per-format converter choice is config, so a hard document type can be routed to Docling without the pipeline knowing. Failures downgrade one file to a warning instead of failing the case. | |
| 212 | +| **Chunker** | Paragraph-first splitting at ~700 tokens with 100 tokens of overlap, falling back to sentence and then hard splits. | Cutting mid-sentence damages both the embedding and the model's reading of the returned snippet. Overlap keeps a fact that straddles a boundary retrievable. | |
| 213 | +| **Vector store** | Qdrant in production, in-memory for dev and tests, behind one `IEvidenceStore` interface. | The access pattern is filtered ANN — always by case, always by category. Qdrant indexes those payload fields, so the filter is pushed down rather than applied after ranking. | |
| 214 | +| **Anchor extraction** | Splits the report on detected headings and attributes each passage to checks by keyword. | Deliberately zero-LLM: cheap, deterministic, nothing to keep in sync. Its only job is narrowing what each check reads; the LLM still quotes the exact fact. No headings found means one whole-document passage — less targeted, still correct. | |
| 215 | +| **Evidence retriever** | Plans queries with the LLM, executes them under mandatory case and category filters, merges the hits. | Splits the decision from the permission. The model chooses what to look for; the retriever decides what it is allowed to look in. | |
| 216 | +| **Aggregator** | Accumulates check results behind a fan-in barrier and emits once the last one arrives. | The runtime delivers one message per source per step, so the barrier target has to hold state and stay silent until the set is complete. | |
| 217 | +| **Report renderer** | Lives in the data service. Renders the Markdown anchor table — line, page, check, provided fact, evidence or gap, decision — plus rationale, and a JSON view, on demand from the stored assessment. | A report is a view over findings, and it must stay renderable long after the run that produced them. Rendering on demand means one source of truth: a corrected finding cannot leave a stale report behind it. Anchors carry their page number because only the workflow holds the parsed report that maps a line to a page — that is the workflow producing data for reporting, not reporting. | |
| 218 | +| **Audit hook** | Framework contract in `Abacus.Run`: a definition declares an `AuditRecordDefinition`, and every executor reaches an `IWorkflowAuditRecorder` through `Runtime.Audit`. | Auditing is opt-in per workflow and costs nothing to one that declines it. The framework supplies the hook and the storage; the shape and the meaning stay with the definition, because only it knows what is audit-significant about its own run. | |
| 219 | +| **Audit store** | Generic root-plus-entries storage with JSON payloads, backed by SQLite in the host and in-memory in the framework default. | Workflow-agnostic on purpose: a new workflow declares new sections and needs no schema change. Writes are best-effort — losing a record must never fail the work it describes. | |
| 220 | +| **Reconciler** | Background service that settles assessments left running when a workflow dead-stops, cancels or exhausts retries. | The happy path already completes the assessment. This exists so the sad paths don't leave a row stuck in `Running` forever. | |
| 221 | + |
| 222 | +--- |
| 223 | + |
| 224 | +## Data model — findings anchored to evidence |
| 225 | + |
| 226 | +The split runs through the schema. One assessment per workflow instance and one finding per check |
| 227 | +live in the data service, each finding carrying the two halves of the audit: where in the report the |
| 228 | +claim was made, and what in the case file supports it. The workflow host's own tables record how it |
| 229 | +got there. |
| 230 | + |
| 231 | +| Entity | Owner | Key | Holds | |
| 232 | +| --- | --- | --- | --- | |
| 233 | +| `Assessment` | Data service | `Id` · unique `WorkflowInstanceId` | Case reference, status and timestamps — no stored report, since the report is rendered from the findings on request. Unique on instance id, so a resumed workflow reuses its assessment. | |
| 234 | +| `Finding` | Data service | unique `(AssessmentId, CheckId)` | Decision and rationale for one check. The uniqueness constraint is what makes a re-run an update. | |
| 235 | +| `Anchor` | Data service | `FindingId` | Line range and the verbatim fact quoted from the suitability report. | |
| 236 | +| `EvidenceHit` | Data service | `FindingId` | Source path, page, snippet and score from the supporting documents. | |
| 237 | +| `AuditRecordRoot` | Audit store | `InstanceId` | The root aggregate: root kind and key (here, the assessment keyed by case reference), status, and attributes including the assessment id it describes. | |
| 238 | +| `AuditRecordEntry` | Audit store | unique `(InstanceId, SectionKind, Key)` | One declared construct with a JSON payload. Keyed so a retried executor corrects its record instead of appending a contradictory second one. | |
| 239 | +| `IngestedDocument` | Workflow host | `(CaseReference, FileHash)` | SHA-256 ledger of embedded files. Re-ingesting an unchanged file is a no-op; changed content replaces its prior chunks. | |
| 240 | + |
| 241 | +--- |
| 242 | + |
| 243 | +## Fault tolerance — degrade the finding, not the assessment |
| 244 | + |
| 245 | +The governing rule: a check that cannot be answered well should still be answered honestly. An |
| 246 | +assessment that stops halfway is worth less to a reviewer than one that completes and says where it |
| 247 | +was blind. |
| 248 | + |
| 249 | +| Failure | Response | |
| 250 | +| --- | --- | |
| 251 | +| **Transient LLM error** | Exponential backoff, three attempts, through the shared `LlmRetry` policy now used by both the planning and verdict calls. | |
| 252 | +| **Retrieval plan won't parse** | Falls back to a single query built from the check itself. A check is still assessed against real evidence, never silently against none. | |
| 253 | +| **A search fails** | That query is skipped and the rest still run. The finding records `retrieval-unavailable` so the gap is visible rather than implied. | |
| 254 | +| **Verdict won't parse** | Recorded as `PotentialConcern` with a note that manual review is required. Retrying malformed JSON just burns tokens. | |
| 255 | +| **Model invents a category** | Dropped by validation against the seeded catalog before the query is built. | |
| 256 | +| **Document won't convert** | Warned and skipped; the remaining documents ingest. Ten checks against a partial corpus beats no assessment. | |
| 257 | +| **SQLite busy or locked** | Classified as retryable — ten check executors write concurrently, so contention is expected rather than exceptional. | |
| 258 | +| **Data service unreachable** | Classified as retryable. It owns the catalog and the findings, so it is a hard dependency — but the assessment is resumable and the work already done is not lost. | |
| 259 | +| **Finding won't publish** | The audit row is written first, recording the error alongside the prompt and response, then the failure propagates. A finding that could not be published is exactly what an investigator needs to see. | |
| 260 | +| **Audit write fails** | Logged and swallowed. The trail is for investigation, not correctness — losing a row must not fail a check whose finding was already published. | |
| 261 | +| **No report, or path missing** | Dead-stop. There is nothing to audit, and retrying will not conjure a file. | |
| 262 | + |
| 263 | +--- |
| 264 | + |
| 265 | +## Configuration — what's tunable without a rebuild |
| 266 | + |
| 267 | +```text |
| 268 | +— Abacus.Data.Service — |
| 269 | +QaData:Database connection string for the catalog + assessments |
| 270 | +QaData:Seed startup seeding toggle and the two CSV paths |
| 271 | +
|
| 272 | +— Abacus.Run.Service — |
| 273 | +Abacus:AuditRecords connection string for the framework's generic audit-record store |
| 274 | +SuitabilityQa:Database connection string for the workflow's ingestion ledger |
| 275 | +SuitabilityQa:DataService base URL and timeout for the data service |
| 276 | +SuitabilityQa:Ingestion chunk size, overlap, excluded categories, per-format converters |
| 277 | +SuitabilityQa:Ocr Tesseract toggle, language, scanned-page threshold |
| 278 | +SuitabilityQa:Docling sidecar URL and timeout — only used if a converter selects it |
| 279 | +SuitabilityQa:VectorStore InMemory or Qdrant, collection, embedding dimensions |
| 280 | +SuitabilityQa:Retrieval MaxQueriesPerCheck 4 · TopKPerQuery 4 · MaxEvidencePerCheck 10 |
| 281 | +SuitabilityQa:Llm model, temperature, max tokens |
| 282 | +``` |
| 283 | + |
| 284 | +The excluded-category list is the single source of truth for the source/evidence split: the scanner |
| 285 | +skips it, the locator looks in it for the report, and the retriever removes it from the catalog |
| 286 | +offered to the planner. Change it in one place and all three follow. |
| 287 | + |
| 288 | +--- |
| 289 | + |
| 290 | +## Known tradeoff — planned retrieval doubles the LLM calls |
| 291 | + |
| 292 | +Ten checks now make ten planning calls and ten verdict calls. The planning call is small and the |
| 293 | +retrieval options cap the work it can generate, but the cost is real. If a deployment values cost |
| 294 | +over retrieval precision, the planner is the component to make optional — the fallback path it |
| 295 | +already uses on a parse failure is exactly the old behaviour. |
| 296 | + |
| 297 | +--- |
| 298 | + |
| 299 | +*Companion to [01 implementation plan](01-implementation-plan.md) · [02 tech stack](02-tech-stack.md) · [03 workflow realization](03-qa-workflow-realization.md)* |
0 commit comments