给别人出报告能读到档案,对话和报告标明 AI 生成

BUG-1063:后台按列只读档案,授权前数据库拒绝读表,授权后通过。对话输入框下和报告标题下加上 AI 生成说明。状态板按已部署提交改措辞,不标 resolved。下载文件元数据没有做。

自测:tsc 退出码 0;lint 0 error;定向测试 51 通过;test:db 82 项里 13 项为 Windows 已知失败,无新增;隐私标记退出码 0。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-14 09:30:51 +08:00
co-authored by Grok (agent loop)
parent f4c53e7f9f
commit fca62fda43
16 changed files with 376 additions and 39 deletions
+2 -2
View File
@@ -339,11 +339,11 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Input and composer
- **Structure:** a column, not a row — warm canvas field on top, the composer's own bottom row beneath it. The row carries the model picker on the left and the send/stop control on the right, with the character count inline beside it.
- **No strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has.
- **No tool strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has. A single line of copy (`.composer-ai-notice`, 2026-10-14) sits under the box: it is not a tool row, has no 44px slot, and on a narrow screen it stays one line and ellipsizes instead of wrapping over the send control.
- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open, **a rectification session whose surface is not yet open**), invalid. Generating does not disable the textarea; the send control becomes stop. On a rectification session the ordinary composer is only a disabled field with placeholder「正在打开生时校正…」; if opening failed, a「重新打开生时校正」button sits above it. No spinner.
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot. The only thing under the box is the one-line AI notice, not another control.
- **Inner field corners:** the textarea inside `.composer` has `border-radius: 0`. Rounding stays on the outer `.composer` only. The field has no horizontal padding, so a radius on the textarea itself clips the first glyph's left edge (BUG-1311).
### Answer start anchor
+6
View File
@@ -239,6 +239,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
星盘、星历、报告列表几秒内就会出现,等待时只放轨道环动画,不在屏幕上写「这一张盘还没拿到。」一类句子;这些句子只给读屏器。首页不再写「上次那次校正还没完成,可以在历史对话里接着做。」。
## AI 生成标识(2026-10-14)
对话输入框下方一行,普通对话和生时校正共用,不进回答正文:「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」英文壳还没有,对应英文先定下:「AI-generated. For reference only; not medical, legal or financial advice.」
报告标题下(中文阅读页、结构化阅读页、英文版)一行:「本报告由 AI 生成,仅供参考。」英文:「This report is AI-generated and for reference only.」报告结尾原有的免责保留,不改生成正文。
## 协议与同意(2026-09-30)
- 勾选框:「我已阅读并同意《用户协议》和《隐私政策》」;未勾选就提交:「请先阅读并勾选同意,再继续。」(只在框下一行,不弹 toast)。
+7
View File
@@ -1850,6 +1850,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
outline-offset: 3px;
}
.composer { width: min(760px, 100%); display: flex; flex-direction: column; gap: var(--space-2); margin: 0 auto; border: 1px solid var(--color-border-strong); transition: border-color 140ms ease-out; padding: var(--space-3) var(--space-3) var(--space-2); border-radius: var(--radius-lg); background: var(--color-canvas); }
.composer-ai-notice { width: min(760px, 100%); margin: var(--space-2) auto 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-overline); line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
/* The composer's own bottom row. It replaces .composer-footer, a strip below
the composer that held a permanent 44px whether or not it had anything to
show. The row only exists when there is a toolbar or a count to render. */
@@ -4352,6 +4353,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.personal-report-ai-notice {
margin: 0 0 var(--space-3);
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
line-height: 1.45;
}
.personal-report-focus-card {
display: grid;
gap: var(--space-3);
@@ -12,6 +12,7 @@ import {
serverComposerDraftSnapshot,
subscribeComposerDraft,
} from "@/lib/composer-draft";
import { CHAT_AI_NOTICE_ZH } from "@/lib/ai-generated-notice";
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
export const composerRemainingId = "composer-character-remaining";
@@ -133,6 +134,7 @@ export function ChatComposer({
</div>
</div>
</form>
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
</>
);
}
@@ -25,6 +25,7 @@ import {
type ReportDocument,
type ReportDocumentV2,
} from "@/lib/personal-report-contract";
import { reportAiNotice } from "@/lib/ai-generated-notice";
import { reportThemeLabel } from "@/lib/personal-report-progress";
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
@@ -191,6 +192,7 @@ function ReportCover({ document, d1 }: { document: ReportDocument; d1: ChartV1 |
<div>
<p className="personal-report-kicker">个人报告</p>
<h1>{document.subject.displayName}</h1>
<p className="personal-report-ai-notice">{reportAiNotice("zh")}</p>
<p className="personal-report-deck">{document.executiveSummary.headline}</p>
</div>
<dl className="personal-report-metadata">
@@ -12,6 +12,7 @@ import {
parseReportChartBlock,
toNorthIndianChart,
} from "@/lib/report-chart-block";
import { reportAiNotice } from "@/lib/ai-generated-notice";
import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block";
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
@@ -69,10 +70,12 @@ function markdownComponents(
h1: ({ children }) => {
const title = flattenText(children).trim();
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
const notice = byline.shown ? null : reportAiNotice(language);
byline.shown = true;
return (
<>
<h1>{children}</h1>
{notice ? <p className="personal-report-ai-notice">{notice}</p> : null}
{line ? <p className="personal-report-md-byline">{line}</p> : null}
</>
);
+14
View File
@@ -0,0 +1,14 @@
import type { ReportLanguage } from "./report-language.ts";
/** Visible label under the chat field. The product shell is Chinese. */
export const CHAT_AI_NOTICE_ZH = "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。";
/** English equivalent, locked for a future English shell. Not shown in the Chinese UI. */
export const CHAT_AI_NOTICE_EN = "AI-generated. For reference only; not medical, legal or financial advice.";
export const REPORT_AI_NOTICE_ZH = "本报告由 AI 生成,仅供参考。";
export const REPORT_AI_NOTICE_EN = "This report is AI-generated and for reference only.";
export function reportAiNotice(language: ReportLanguage): string {
return language === "en" ? REPORT_AI_NOTICE_EN : REPORT_AI_NOTICE_ZH;
}
@@ -0,0 +1,37 @@
-- BUG-1063: the report worker reads another person's archive through
-- service_role (loadSubjectBirth → CHART_SUBJECT_SELECT). The 2026-07-18
-- repair revoked every privilege on chart_profiles and never granted SELECT
-- back. Column-level SELECT only, matching CHART_SUBJECT_SELECT. No write,
-- no RLS change, no change to authenticated grants.
begin;
grant select (
id,
user_id,
role,
name,
birth_date,
reported_birth_time,
birth_time_source,
birth_time_period,
declared_window_start,
declared_window_end,
uncertainty_before_minutes,
uncertainty_after_minutes,
latitude,
longitude,
timezone_id,
timezone_offset,
birth_place_label,
birth_place_type,
birth_place_provider,
birth_place_provider_id,
ayanamsa,
active_birth_time,
active_birth_date,
active_birth_timezone_offset,
birth_time_status,
gender
) on table public.chart_profiles to service_role;
commit;
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { createElement, createRef } from "react";
import { readFileSync } from "node:fs";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatComposer } from "../src/components/chat-composer.tsx";
import { PersonalReportDocumentView } from "../src/components/personal-report/personal-report-document-view.tsx";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
import {
CHAT_AI_NOTICE_EN,
CHAT_AI_NOTICE_ZH,
REPORT_AI_NOTICE_EN,
REPORT_AI_NOTICE_ZH,
} from "../src/lib/ai-generated-notice.ts";
import type { LegacyReportDocumentV1 } from "../src/lib/personal-report-contract.ts";
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const rectification = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const fixture = JSON.parse(readFileSync(
new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url),
"utf8",
)) as LegacyReportDocumentV1;
function composerHtml(): string {
return renderToStaticMarkup(createElement(ChatComposer, {
inputRef: createRef<HTMLTextAreaElement>(),
value: "",
inputLabel: "输入你的问题",
placeholder: "想聊什么都可以",
maxLength: 500,
inputDisabled: false,
submitLabel: "发送",
submitBlocked: false,
stopVisible: false,
stopLabel: "停止回答",
stopTitle: "停止回答",
onSubmit() {},
onChange() {},
onKeyDown() {},
onStop() {},
}));
}
test("both chat surfaces share the composer notice, under the field and not over the send control", () => {
assert.equal(CHAT_AI_NOTICE_ZH, "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。");
assert.equal(CHAT_AI_NOTICE_EN, "AI-generated. For reference only; not medical, legal or financial advice.");
const html = composerHtml();
const noticeAt = html.indexOf("composer-ai-notice");
const sendAt = html.indexOf("发送");
assert.ok(noticeAt > sendAt, "the notice follows the send control");
assert.match(html, new RegExp(CHAT_AI_NOTICE_ZH));
assert.match(page, /<ChatComposer/);
assert.match(rectification, /<ChatComposer/);
assert.doesNotMatch(page, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.doesNotMatch(rectification, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*white-space:\s*nowrap/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*overflow:\s*hidden/);
assert.doesNotMatch(styles, /\.composer-footer\s*\{/);
});
test("report readers show the AI notice in Chinese and English under the title", () => {
assert.equal(REPORT_AI_NOTICE_ZH, "本报告由 AI 生成,仅供参考。");
assert.equal(REPORT_AI_NOTICE_EN, "This report is AI-generated and for reference only.");
const chinese = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# 虚构报告\n\n一段说明。",
language: "zh",
}));
const english = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# Fictional report\n\nA paragraph.",
language: "en",
}));
const document = renderToStaticMarkup(createElement(PersonalReportDocumentView, { document: fixture }));
assert.ok(chinese.indexOf("<h1>") < chinese.indexOf(REPORT_AI_NOTICE_ZH));
assert.match(english, new RegExp(REPORT_AI_NOTICE_EN.replaceAll(".", "\\.")));
assert.match(document, new RegExp(REPORT_AI_NOTICE_ZH));
assert.match(document, /id="report-disclaimer"/);
});
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import test from "node:test";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
// BUG-1063: every column loadSubjectBirth selects from chart_profiles must
// have a service_role SELECT grant after the 2026-07-18 revoke.
const REVOKE = "20260718100000";
const directory = new URL("../supabase/migrations/", import.meta.url);
const grantFile = new URL(
"../supabase/migrations/20261014010000_chart_profiles_service_role_select.sql",
import.meta.url,
);
function serviceRoleChartSelectGrants(): Set<string> {
const granted = new Set<string>();
for (const name of readdirSync(directory).filter((file) => file.endsWith(".sql")).sort()) {
if (name.slice(0, 14) < REVOKE) continue;
const sql = readFileSync(new URL(name, directory), "utf8").replace(/--.*$/gm, "");
const pattern = /grant\s+([a-z ,]+?)\s*\(([^)]*)\)\s*on\s+table\s+public\.chart_profiles\s+to\s+([a-z_, ]+);/gi;
for (const match of sql.matchAll(pattern)) {
if (!/select/i.test(match[1]!) || !/service_role/.test(match[3]!)) continue;
for (const column of match[2]!.split(",")) {
const trimmed = column.trim();
if (trimmed) granted.add(trimmed);
}
}
}
return granted;
}
test("service_role chart_profiles select covers CHART_SUBJECT_SELECT and nothing writable", () => {
const sql = readFileSync(grantFile, "utf8").replace(/--.*$/gm, "");
assert.doesNotMatch(sql, /grant\s+(insert|update|delete)\b/i);
assert.doesNotMatch(sql, /create\s+policy|alter\s+policy|enable\s+row\s+level\s+security/i);
assert.doesNotMatch(sql, /to\s+authenticated/i);
const missing = CHART_SUBJECT_SELECT.split(",").map((column) => column.trim()).filter((column) => !serviceRoleChartSelectGrants().has(column));
assert.deepEqual(missing, []);
});
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subject.ts";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
// BUG-1063: the report worker reads another person's archive through
// service_role. Fictional rows only.
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const SERVICE_URL_ROLE = ["service_runtime", "service-runtime-test-password"] as const;
function applyMigrations(fixture: PostgresFixture): void {
const result = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
}
test("service_role can read another person's chart profile for a report", {
skip: docker ? false : "docker unavailable",
}, async () => {
const fixture = startPostgresFixture();
const serviceUrl = fixture.connectionUrl(...SERVICE_URL_ROLE);
try {
applyMigrations(fixture);
const owner = randomUUID();
const subjectId = randomUUID();
fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at)
values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`);
fixture.psql(`insert into public.chart_profiles (
id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time,
birth_time_source, latitude, longitude, timezone_id, timezone_offset,
birth_place_label, ayanamsa, birth_time_status
) values (
'${subjectId}', '${owner}', 'other', '{"name":"虚构乙"}'::jsonb, now(),
'虚构乙', '1990-01-01', '08:00', 'family_exact', 22.3, 114.1,
'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported'
);`);
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
const probe = await admin
.from("chart_profiles")
.select(CHART_SUBJECT_SELECT)
.eq("id", subjectId)
.maybeSingle();
assert.equal(probe.error, null, probe.error?.message ?? "chart_profiles select failed");
const loaded = await loadReportWorkerProfile(admin, owner, subjectId) as { name?: string };
assert.equal(loaded.name, "虚构乙");
for (const column of CHART_SUBJECT_SELECT.split(",")) {
assert.equal(
fixture.psql(`select has_column_privilege('service_role', 'public.chart_profiles', '${column}', 'select')`),
"t",
column,
);
}
for (const privilege of ["insert", "update", "delete"]) {
assert.equal(
fixture.psql(`select has_table_privilege('service_role', 'public.chart_profiles', '${privilege}')`),
"f",
privilege,
);
}
assert.equal(
fixture.psql("select has_table_privilege('authenticated', 'public.chart_profiles', 'select')"),
"t",
);
} finally {
await closeLocalPostgresDataPool(serviceUrl);
fixture.stop();
}
});