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:
parent
f32545ed92
commit
b4bff014b3
@ -5,7 +5,7 @@ import type { Slice } from '../api/charts';
|
||||
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
|
||||
import { Badge, Card, Facts } from './core';
|
||||
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.
|
||||
@ -63,12 +63,7 @@ export function Verdict({
|
||||
)}
|
||||
<span className="ml-auto">AI analysis · Ava</span>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
{scanned && <WorkStatement text={scanned} />}
|
||||
</div>
|
||||
|
||||
<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. */
|
||||
export function Recommendations({ row }: { row: Row }) {
|
||||
const recs = recommendations(row.recommendations).map(prose);
|
||||
|
||||
@ -184,6 +184,28 @@ export function firstDeadline(recs: unknown): string | null {
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function longDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
|
||||
@ -4,7 +4,7 @@ import { useQuery, useZino } from '../api/provider';
|
||||
import { FINDING_KIND, QUEUES, RECORD_VIEW, STAGE_TONE } from '../api/config';
|
||||
import { Badge, Button, Empty, ErrorNote, Spinner } from '../components/core';
|
||||
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.
|
||||
@ -157,7 +157,11 @@ function CaseCard({ row, onOpen }: { row: Row; onOpen: () => void }) {
|
||||
)}
|
||||
<ChevronRight className="mt-1 h-4 w-4 shrink-0 text-faint" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user