feat: review coverage as rows of facts, not a paragraph

Ava now writes the work statement as four labelled lines (REVIEWED / FOUND /
WATCHED / CLEAR, items separated by ';'); the case page renders each as a row
of short chips under 'Review coverage'. Older prose falls back to sentence
bullets. Charter also asks for a <=25-word first sentence and plain English —
no column names or SQL in prose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-16 22:53:27 +05:30
parent f32545ed92
commit b4bff014b3
3 changed files with 65 additions and 9 deletions

View File

@ -5,7 +5,7 @@ import type { Slice } from '../api/charts';
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink'; import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
import { Badge, Card, Facts } from './core'; import { Badge, Card, Facts } from './core';
import type { AvaDecision, AvaTraceStep, Row } from '../api/types'; import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
import { firstDeadline, headline, money, num, pct, prose, recommendations, shortDate, splitRecommendation, sqlSteps, str } from '../format'; import { firstDeadline, headline, money, num, pct, prose, recommendations, sentences, shortDate, splitRecommendation, sqlSteps, str, workStatement } from '../format';
/** /**
* Ava's findings, laid out for a person who has ninety seconds. * Ava's findings, laid out for a person who has ninety seconds.
@ -63,12 +63,7 @@ export function Verdict({
)} )}
<span className="ml-auto">AI analysis · Ava</span> <span className="ml-auto">AI analysis · Ava</span>
</div> </div>
{scanned && ( {scanned && <WorkStatement text={scanned} />}
<p className="mt-3 border-t border-ai-edge/60 pt-3 text-[13px] leading-relaxed text-muted">
<span className="font-semibold text-ink">What Ava reviewed. </span>
{scanned}
</p>
)}
</div> </div>
<div className="grid gap-px bg-line sm:grid-cols-3"> <div className="grid gap-px bg-line sm:grid-cols-3">
@ -96,6 +91,41 @@ export function Verdict({
); );
} }
/** What was covered, as rows of short facts — not a paragraph. Ava writes it
* as labelled lines; older cases wrote prose, which falls back to sentence
* bullets so nothing renders as a wall of text. */
function WorkStatement({ text }: { text: string }) {
const rows = workStatement(text);
const TONE: Record<string, string> = { FOUND: 'text-warn', WATCHED: 'text-muted', CLEAR: 'text-good', REVIEWED: 'text-ink' };
return (
<div className="mt-3 border-t border-ai-edge/60 pt-3">
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">Review coverage</p>
{rows ? (
<dl className="mt-2 grid gap-y-2 text-[13px] sm:grid-cols-[9.5rem_1fr] sm:gap-x-4">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className={`font-semibold ${TONE[r.key] ?? 'text-ink'}`}>{r.label}</dt>
<dd className="flex flex-wrap gap-x-1.5 gap-y-1">
{r.items.map((it, i) => (
<span key={i} className="rounded-md bg-panel px-2 py-0.5 text-ink ring-1 ring-line ring-inset">
{it}
</span>
))}
</dd>
</div>
))}
</dl>
) : (
<ul className="mt-2 list-disc space-y-1 pl-4 text-[13px] text-muted">
{sentences(text).map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
)}
</div>
);
}
/** The decision list. Rendered directly above the Approve / Send back / Reject buttons. */ /** The decision list. Rendered directly above the Approve / Send back / Reject buttons. */
export function Recommendations({ row }: { row: Row }) { export function Recommendations({ row }: { row: Row }) {
const recs = recommendations(row.recommendations).map(prose); const recs = recommendations(row.recommendations).map(prose);

View File

@ -184,6 +184,28 @@ export function firstDeadline(recs: unknown): string | null {
} }
/** 2026-10-02 → "Oct 2" */ /** 2026-10-02 → "Oct 2" */
/** Ava's work statement: labelled lines ("REVIEWED: a; b; c"). Returns the
* rows in her order, items split on ";" — or null when the text is an older
* free-form paragraph, in which case the caller falls back to sentences. */
export const WORK_LABELS: Record<string, string> = {
REVIEWED: 'Reviewed',
FOUND: 'Found',
WATCHED: 'Watched, below the bar',
CLEAR: 'Checked, nothing',
};
export function workStatement(v: unknown): Array<{ key: string; label: string; items: string[] }> | null {
const lines = str(v).split(/\n+/).map((l) => l.trim()).filter(Boolean);
const rows: Array<{ key: string; label: string; items: string[] }> = [];
for (const line of lines) {
const m = line.match(/^([A-Z][A-Z ]{2,20}):\s*(.*)$/);
if (!m) continue;
const key = m[1].trim();
const items = m[2].split(/;\s*/).map((x) => x.trim().replace(/[.]$/, '')).filter(Boolean);
rows.push({ key, label: WORK_LABELS[key] ?? key.charAt(0) + key.slice(1).toLowerCase(), items });
}
return rows.length >= 2 ? rows : null;
}
/** 2026-07-26 → "Jul 26, 2026". For prose; shortDate is for axes and chips. */ /** 2026-07-26 → "Jul 26, 2026". For prose; shortDate is for axes and chips. */
export function longDate(iso: string | null): string { export function longDate(iso: string | null): string {
if (!iso) return ''; if (!iso) return '';

View File

@ -4,7 +4,7 @@ import { useQuery, useZino } from '../api/provider';
import { FINDING_KIND, QUEUES, RECORD_VIEW, STAGE_TONE } from '../api/config'; import { FINDING_KIND, QUEUES, RECORD_VIEW, STAGE_TONE } from '../api/config';
import { Badge, Button, Empty, ErrorNote, Spinner } from '../components/core'; import { Badge, Button, Empty, ErrorNote, Spinner } from '../components/core';
import type { Row } from '../api/types'; import type { Row } from '../api/types';
import { dateTime, headline, money, num, prose, str } from '../format'; import { dateTime, headline, money, num, prose, sentences, str, workStatement } from '../format';
/** /**
* A queue, as a morning briefing. * A queue, as a morning briefing.
@ -157,7 +157,11 @@ function CaseCard({ row, onOpen }: { row: Row; onOpen: () => void }) {
)} )}
<ChevronRight className="mt-1 h-4 w-4 shrink-0 text-faint" /> <ChevronRight className="mt-1 h-4 w-4 shrink-0 text-faint" />
</div> </div>
{quiet && scanned && <p className="mt-1.5 line-clamp-2 text-xs text-muted">{scanned}</p>} {quiet && scanned && (
<p className="mt-1.5 line-clamp-2 text-xs text-muted">
{(workStatement(scanned)?.find((r) => r.key === 'REVIEWED')?.items ?? sentences(scanned).slice(0, 1)).join(' · ')}
</p>
)}
</button> </button>
); );
} }