mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(coach): attach multiple documents to a conversation
Replace the scalar document_id discriminator on a coach conversation with a CoachConversationDocument join table and a sticky documentScoped flag, so a conversation can be grounded on several stored documents at once while the prompt-injection fence stays intact across the data-model change. The fence is re-established at both enforcement points, fail-closed: - Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a fresh chat with staged attachments) to the new /api/insights/chat/fenced endpoint; the fenced body is strict and never carries scope, guided-question, or prefill hints. - Server: the tool route fetches with documentScoped:false AND 404s when the fetched row still carries an attachment (flag/join drift), emitting a fence_drift audit alarm. Document text now enters an LLM prompt in exactly one code path — the fenced pipeline — with no tools and no health snapshot; a module-graph test pins that the tool loop is unreachable from it. The fenced turn fences each document separately with a server-built, scrubbed header line (title and filename are untrusted text), runs a per-document egress consent check on the single picked provider, and grounds numerically over the live attachment union only. Attachments are capped at five documents and 192 KB of combined text with honest truncation. Attach and detach ride dedicated endpoints: attaching validates the document is owned, live, indexed, and within cap, then flips the conversation fenced (the one legal, privilege-reducing transition, audit-logged); detaching removes the document but never clears the flag. The document-sheet chat keeps its public wire contract and shares the same fenced pipeline over the join. Migration 0245 adds the join table and the flag, backfills every legacy single-document chat into a one-attachment fenced conversation, and drops the scalar column in the same transaction — no dual-truth window.
This commit is contained in:
1 parent
d72f078764
commit
502783a3ea
45 files changed
+4541
-861
No files matched your search
+250
-15
@@ -5281,6 +5281,124 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/chat/fenced:
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Send a FENCED multi-document coach turn (streaming reply)
|
||||
description: "v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a
|
||||
coach conversation, as Server-Sent Events (`text/event-stream`: one `data: <json>\\n\\n` frame per event;
|
||||
`token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO
|
||||
health snapshot; every attached document is fenced as untrusted DATA (per-document header fields
|
||||
marker-scrubbed); the single picked provider is egress-consent-checked once per attached document (403
|
||||
`consent.ai.required` if any fails); the reply is numerically grounded against the LIVE attachments' figures
|
||||
only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5)
|
||||
creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` /
|
||||
`prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`;
|
||||
rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FencedCoachChatRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Server-Sent Events stream of `token` / `done` / `error` frames.
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
description: "SSE frames: `data: <json>\\n\\n`. See the operation description for the per-`type` frame shapes."
|
||||
"401": *a1
|
||||
"403":
|
||||
description: "AI consent required for an external provider (`errorCode: consent.ai.required`). A 422 (`stdResponses`)
|
||||
covers an un-indexed / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap
|
||||
(`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId`
|
||||
(`coach.fenced.attachmentConflict`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/chat/{id}/attachments:
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Attach a document to a coach conversation
|
||||
description: "v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets
|
||||
its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged
|
||||
when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent:
|
||||
attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404.
|
||||
Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Conversation id.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachAttachmentCreateRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: The refreshed live attachment set + the fenced flag.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachAttachmentsResponse"
|
||||
"401": *a1
|
||||
"404":
|
||||
description: Conversation or document not found / not owned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/chat/{id}/attachments/{documentId}:
|
||||
delete:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Detach a document from a coach conversation
|
||||
description: v1.29.x (S7) — removes the document from the conversation. The conversation stays FENCED (the sticky flag
|
||||
is never cleared — a thread whose history may contain document-derived text must never regain the tool loop).
|
||||
Detaching the last document leaves a fenced conversation with zero attachments. A missing row / foreign
|
||||
conversation maps to 404. Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Conversation id.
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The attached document's id.
|
||||
responses:
|
||||
"200":
|
||||
description: The refreshed live attachment set + the fenced flag.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachAttachmentsResponseDetach"
|
||||
"401": *a1
|
||||
"404":
|
||||
description: Attachment or conversation not found / not owned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/layout:
|
||||
get:
|
||||
tags:
|
||||
@@ -12110,6 +12228,43 @@ components:
|
||||
supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact.
|
||||
`medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days —
|
||||
`null` switches the alert off.
|
||||
FencedCoachChatRequest:
|
||||
type: object
|
||||
properties:
|
||||
conversationId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
message:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 4000
|
||||
locale:
|
||||
type: string
|
||||
enum:
|
||||
- en
|
||||
- de
|
||||
attachmentIds:
|
||||
minItems: 1
|
||||
maxItems: 5
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- message
|
||||
additionalProperties: false
|
||||
CoachAttachmentCreateRequest:
|
||||
type: object
|
||||
properties:
|
||||
documentId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- documentId
|
||||
additionalProperties: false
|
||||
InsightsLayoutBody:
|
||||
type: object
|
||||
properties:
|
||||
@@ -21954,15 +22109,20 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
documentId:
|
||||
description: When set, this thread is scoped to one stored document (a document chat, run on the hardened fenced
|
||||
endpoint). Null/absent for a normal health thread.
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
fenced:
|
||||
type: boolean
|
||||
description: "v1.29.x (S7) — the sticky fence flag. true = a FENCED thread: its turns route through the hardened fenced
|
||||
endpoint (no tools, no health snapshot), never the tool route. Permanent once true. false = a normal health
|
||||
thread."
|
||||
attachments:
|
||||
description: v1.29.x (S7) — the LIVE set of documents attached to this thread. Empty on a health thread (or a fenced
|
||||
thread whose attachments were all detached).
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CoachConversationAttachment"
|
||||
documentTitle:
|
||||
description: The owning document's resolved title (title, falling back to filename) for the scope badge. Null on a
|
||||
health thread or when the document has neither.
|
||||
description: v1.29.x (S7) — the FIRST attachment's resolved title, kept for the rail's single-line badge. Null on a
|
||||
health thread or a fenced thread with no live attachment.
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
@@ -21972,8 +22132,24 @@ components:
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- messageCount
|
||||
- fenced
|
||||
additionalProperties: false
|
||||
description: Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt.
|
||||
CoachConversationAttachment:
|
||||
type: object
|
||||
properties:
|
||||
documentId:
|
||||
type: string
|
||||
title:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: The attached document's resolved title (title, falling back to filename). Null when it has neither.
|
||||
Plaintext; no health values.
|
||||
required:
|
||||
- documentId
|
||||
- title
|
||||
additionalProperties: false
|
||||
CoachConversationDetailResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -22020,14 +22196,22 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
documentId:
|
||||
description: When set, this thread is scoped to one stored document; the client routes its turns through the hardened
|
||||
fenced document endpoint, never the tool route.
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
fenced:
|
||||
type: boolean
|
||||
description: v1.29.x (S7) — the sticky fence flag; the client routes a fenced thread's turns through the hardened fenced
|
||||
endpoint, never the tool route.
|
||||
attachments:
|
||||
description: v1.29.x (S7) — the LIVE set of attached documents (join rows), attach-time order.
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CoachConversationAttachment"
|
||||
attachmentCount:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: v1.29.x (S7) — count of live attachment rows (equal to attachments.length).
|
||||
documentTitle:
|
||||
description: The owning document's resolved title for the scope badge. Null on a health thread.
|
||||
description: v1.29.x (S7) — the first attachment's resolved title for the rail badge. Null on a health thread.
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
@@ -22038,6 +22222,8 @@ components:
|
||||
- updatedAt
|
||||
- messageCount
|
||||
- messages
|
||||
- fenced
|
||||
- attachmentCount
|
||||
additionalProperties: false
|
||||
description: Full conversation with every message decrypted server-side. The client renders the bodies directly; no
|
||||
decryption key is involved.
|
||||
@@ -22193,6 +22379,55 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachAttachmentsResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CoachAttachments"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachAttachments:
|
||||
type: object
|
||||
properties:
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CoachConversationAttachment"
|
||||
description: The conversation's refreshed live attachment set.
|
||||
fenced:
|
||||
type: boolean
|
||||
description: The sticky fence flag — always true after an attach; unchanged (stays true) by a detach.
|
||||
required:
|
||||
- attachments
|
||||
- fenced
|
||||
additionalProperties: false
|
||||
CoachAttachmentsResponseDetach:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CoachAttachments"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayout:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in new issue
Block a user