feat(advisor): Phase 1 — endpoint backward-compat (keep breakpilot-workspace working)

The advisor endpoint now serves two shapes off one orchestration:
- new FE ({question}) -> v3 JSON contract (clarity/answer/evidence/citations/...).
- legacy consumer ({message}, e.g. breakpilot-workspace which reads a text stream and
  persists raw bytes) -> plain-text stream of the L2 answer (clean prose, no [n] markup,
  no clarify gate). isLegacyRequest() discriminates; answerSystem() gains withCitations.

Prevents the v3 contract from breaking breakpilot-workspace's chat (CLAUDE.md rule #4,
keep every consumer working). No deploy. tsc clean, 13 vitest (incl. isLegacyRequest),
check-loc 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-07-01 13:53:17 +02:00
parent 5a513181cc
commit 3f372bcb39
3 changed files with 57 additions and 6 deletions
@@ -11,9 +11,10 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { readSoulFile } from '@/lib/sdk/agents/soul-reader' import { readSoulFile } from '@/lib/sdk/agents/soul-reader'
import { retrieveFull } from '@/lib/sdk/agents/advisor-rag' import { retrieveFull } from '@/lib/sdk/agents/advisor-rag'
import { completeAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm' import { completeAdvisorAnswer, streamAdvisorAnswer, type ChatMessage } from '@/lib/sdk/agents/advisor-llm'
import { import {
buildCitations, buildCitations,
isLegacyRequest,
mapClarity, mapClarity,
mapFootnotes, mapFootnotes,
numberedEvidenceForPrompt, numberedEvidenceForPrompt,
@@ -44,15 +45,24 @@ folgen belegte Quellen. Wenn der Begriff in mehreren Bereichen vorkommt, erwaehn
const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.` const FALLBACK_SYSTEM = `Du bist der BreakPilot Compliance-Berater. Antworte quellenbasiert, verstaendlich und ehrlich auf Deutsch.`
function answerSystem(soul: string | null, country: Country | undefined, evidenceBlock: string): string { function answerSystem(
soul: string | null,
country: Country | undefined,
evidenceBlock: string,
withCitations = true,
): string {
let s = soul || FALLBACK_SYSTEM let s = soul || FALLBACK_SYSTEM
if (country) s += countryBlock(country) if (country) s += countryBlock(country)
s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}` s += `\n\n## Belegte Evidence (nummeriert — DEINE EINZIGEN Quellen)\n${evidenceBlock || '(keine Evidence gefunden)'}`
s += `\n\n## Antwortformat (WICHTIG) s += `\n\n## Antwortformat (WICHTIG)
- Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe. - Gut gegliedertes Markdown: kurze ## Ueberschriften je Aspekt, Aufzaehlungen, **Fettung** fuer Kernbegriffe.`
- Belege Kernaussagen mit [n], wobei n die NUMMER der Evidence-Quelle oben ist (z. B. [1], [2]). if (withCitations) {
- Nenne KEINE Quellen-/Fundstellen-Liste im Fliesstext — die Quellen werden dem Nutzer separat angezeigt. s += `\n- Belege Kernaussagen mit [n], wobei n die NUMMER der Evidence-Quelle oben ist (z. B. [1], [2]).
- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.` - Nenne KEINE Quellen-/Fundstellen-Liste im Fliesstext — die Quellen werden dem Nutzer separat angezeigt.`
} else {
s += `\n- Nenne Fundstellen nur, wo sie der Antwort dienen (natuerlich im Text, KEIN [n]-Markup).`
}
s += `\n- Triff KEINE Aussage, die nicht durch die nummerierte Evidence belegt ist; fehlt der Beleg, sage das offen.`
return s return s
} }
@@ -70,6 +80,28 @@ export async function POST(request: NextRequest) {
} }
const retrieved = await retrieveFull(question, context) const retrieved = await retrieveFull(question, context)
// Backward-compat: legacy consumers (breakpilot-workspace) send {message} and read a plain-text
// stream. Serve the L2 answer streamed (clean prose, no [n]); no clarify gate, no JSON.
if (isLegacyRequest(body)) {
const legacyEvidence = retrieved.evidence ?? []
const legacySoul = await readSoulFile('compliance-advisor')
const legacyStream = await streamAdvisorAnswer([
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false) },
{ role: 'user', content: question },
])
if (!legacyStream) {
return NextResponse.json({ error: 'LLM nicht erreichbar.' }, { status: 502 })
}
return new NextResponse(legacyStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'X-Advisor-Format': 'legacy-stream',
},
})
}
const mode = resolveMode(retrieved.clarity?.mode, !!context) const mode = resolveMode(retrieved.clarity?.mode, !!context)
if (mode === 'clarify') { if (mode === 'clarify') {
@@ -5,6 +5,7 @@ import {
mapFootnotes, mapFootnotes,
buildCitations, buildCitations,
numberedEvidenceForPrompt, numberedEvidenceForPrompt,
isLegacyRequest,
} from '../advisor/retrieve-mapping' } from '../advisor/retrieve-mapping'
import type { EvidenceUnit } from '../advisor/contract' import type { EvidenceUnit } from '../advisor/contract'
@@ -68,3 +69,13 @@ describe('mapFootnotes', () => {
}) })
}) })
}) })
describe('isLegacyRequest', () => {
it('message-only (workspace) -> legacy stream', () => {
expect(isLegacyRequest({ message: 'Ist meine DSE ausreichend?' })).toBe(true)
})
it('question present -> contract (JSON)', () => {
expect(isLegacyRequest({ question: 'x', message: 'y' })).toBe(false)
expect(isLegacyRequest({ question: 'x' })).toBe(false)
})
})
@@ -81,3 +81,11 @@ export function numberedEvidenceForPrompt(evidence: EvidenceUnit[]): string {
}) })
.join('\n\n') .join('\n\n')
} }
/**
* Backward-compat discriminator: legacy consumers (e.g. breakpilot-workspace) send `{message}`
* and read a plain-text stream; the new FE sends `{question}` and expects the JSON contract.
*/
export function isLegacyRequest(body: { question?: unknown; message?: unknown }): boolean {
return body.question == null && typeof body.message === 'string'
}