Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/actions/create-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export async function createMockRunAction(
_prevState: CreateRunFormState,
formData: FormData,
): Promise<CreateRunFormState> {
// DB列名互換のためフォーム名はquestionを維持。
// UI上はResearch Topicとして扱い、既存API/永続化互換を壊さない。
const raw = formData.get("question");
if (typeof raw !== "string" || !raw.trim()) {
return { error: "Research topic is required." };
Expand All @@ -31,6 +33,8 @@ export async function createMockRunAction(
return { error: manualSourceUrlsResult.message };
}

// runはowner scopeで分離する。未認証で作成させないことで
// 履歴閲覧/共有リンク管理の境界を単純に保つ。
const currentUser = await getCurrentUser();
if (!currentUser) {
return { error: "Sign in is required to start an investigation." };
Expand Down
3 changes: 3 additions & 0 deletions src/app/share/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type SharePageProps = {
params: Promise<{ token: string }>;
};

// 共有ページはトークン知識ベースの限定公開。
// 検索エンジン経由の露出を避けるため noindex/nofollow を強制する。
export const metadata = {
robots: {
index: false,
Expand Down Expand Up @@ -87,6 +89,7 @@ export default async function SharePage({ params }: SharePageProps) {
const run = shareLink.analysisRun;
const { answer, sources } = selectLatestAnswerSnapshotForView(run.answerSnapshots);

// 共有閲覧者には再実行権限がないため、失敗時は安全文言のみを表示する。
if (run.status === "failed") {
return (
<main>
Expand Down
4 changes: 4 additions & 0 deletions src/features/run/components/run-result-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ function edgeEndpoints(
};
}

// TraceMapはチャット時系列ではなく調査導線を重視するため、
// Mission -> Evidence -> Unknown -> Lineage -> Report の順を固定表示する。
const INVESTIGATION_GUIDE_STEPS = [
"Mission",
"Evidence Map",
Expand All @@ -210,6 +212,8 @@ export function RunResultView({
answerModel,
answerGeneratedAt,
}: RunResultViewProps) {
// グラフ/クレーム/ソース詳細を連動させる選択状態。
// どれかを起点にしても同じ調査対象へフォーカスできるようにする。
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<string | null>(
null,
Expand Down
2 changes: 2 additions & 0 deletions src/features/run/lib/build-source-lineage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ function normalizeVerificationStatus(value: string | null | undefined): string |
return value === "verified" ? "verified" : value ?? null;
}

// Source Lineage Lite は厳密な出典系譜DBではなく、
// sourceType / primary判定 / publishedAt など既存スナップショットから推定する軽量ビュー。
export function buildSourceLineage(params: {
sources: SourceForLineage[];
evidenceClaims: RunEvidenceClaim[];
Expand Down
4 changes: 4 additions & 0 deletions src/features/run/lib/build-unknowns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ function fromAlert(params: { id: string; message: string; level: AlertLevel; cla
return { id: params.id, relatedClaimIds: params.claimSummary ? [params.id.split("-")[0]] : [], relatedSourceIds: [], text: params.claimSummary ? `${params.claimSummary}: ${params.message}` : params.message, reason: m.includes("primary source") ? "Primary or official evidence is missing." : "Existing evidence raised an investigation caveat.", severity: severityFromAlertLevel(params.level), category, suggestedNextAction: next, signals: ["alert"] };
}

// Unknown Mapは専用テーブルではなく、
// claim/alert/source-qualityから派生する調査上の「未解決点ビュー」。
export function buildUnknowns(params: { evidenceAlerts: RunEvidenceAlert[]; evidenceClaims: RunEvidenceClaim[]; sourceQuality?: SourceQualitySignal[]; sourceDrilldown?: SourceDetailDrilldown[] }): InvestigationUnknown[] {
const unknowns: InvestigationUnknown[] = [];
params.evidenceAlerts.forEach((a) => unknowns.push(fromAlert(a)));
Expand All @@ -40,6 +42,8 @@ export function buildUnknowns(params: { evidenceAlerts: RunEvidenceAlert[]; evid
});
});

// 同一論点の重複表示を避けるため、category+関連ID+reasonで集約し、
// severityが高いものを優先して残す。
const deduped = new Map<string, InvestigationUnknown>();
for (const u of unknowns) {
const key = `${u.category}|${(u.relatedClaimIds ?? []).sort().join(",")}|${(u.relatedSourceIds ?? []).sort().join(",")}|${u.reason.toLowerCase()}`;
Expand Down
7 changes: 7 additions & 0 deletions src/server/analysis/create-analysis-run-from-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export async function createAnalysisRunFromProvider(
options.mode ?? process.env.TRACEMAP_INVESTIGATION_MODE?.trim(),
);

// 手動URL指定時はユーザーが明示した調査条件を優先し、
// 既存run cacheの再利用で入力意図が薄まることを避ける。
const hasManualSourceUrls = (options.manualSourceUrls?.length ?? 0) > 0;

const run = await prisma.analysisRun.create({
Expand All @@ -46,6 +48,8 @@ export async function createAnalysisRunFromProvider(
},
});

// MVP v2 は同期実行のため、run status は queued -> processing -> completed/failed を
// この関数内で完結させる。
await prisma.analysisRun.update({
where: { id: run.id },
data: { status: "processing" },
Expand All @@ -61,6 +65,8 @@ export async function createAnalysisRunFromProvider(
let payload: GeneratedAnswerGraphPayload | null = null;
let shouldStoreRunCache = false;

// run cacheは「同一topic + provider + mode」でのみ再利用する。
// 手動URL付きrunは上書き条件なのでlookup自体を行わない。
if (!hasManualSourceUrls) {
try {
const cached = await lookupRunCacheEntry(cacheKeyInfo);
Expand Down Expand Up @@ -100,6 +106,7 @@ export async function createAnalysisRunFromProvider(
result = await provider.generateAnswerGraph({ question, sourceCandidates: sourceIntake.candidates, mode });
} catch (cause) {
console.error("[analysis] generateAnswerGraph threw", { runId: run.id, cause });
// provider例外は生メッセージをUIへ出さず、安全な固定文言へ変換して保存する。
await prisma.analysisRun.update({
where: { id: run.id },
data: {
Expand Down
5 changes: 5 additions & 0 deletions src/server/analysis/persist-generated-answer-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export async function persistGeneratedAnswerGraph(params: {
}): Promise<void> {
const { runId, payload } = params;

// source検証はbest-effort。到達不能でもrun全体は継続し、
// Unknown/Source Quality側で「注意情報」として表示できる形に寄せる。
const verificationByIndex = await Promise.all(
payload.sources.map((src) =>
shouldResolveSourceCache(src.url)
Expand Down Expand Up @@ -150,6 +152,8 @@ export async function persistGeneratedAnswerGraph(params: {
idMap.set(placeholderId, row.id);
}

// providerは仮ID(__src_n__)を返すため、永続化後のsourceSnapshotIdへ張り替えて
// graph snapshotをDB参照可能な形に正規化する。
const graph = replaceGraphSourceIds(payload.answer.graphJson, idMap);

await tx.answerSnapshot.update({
Expand All @@ -170,6 +174,7 @@ export async function persistGeneratedAnswerGraph(params: {
},
});

// claim-source参照はsupports優先。legacy配列は後方互換としてdirect supportへ変換。
const supportRelations = c.supports
? c.supports.flatMap((support) => {
const sourceSnapshotId = idMap.get(support.sourcePlaceholderId);
Expand Down
4 changes: 4 additions & 0 deletions src/server/analysis/source-intake/source-intake-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export async function buildSourceIntakeFromQuestion(
const discoveryProvider = resolveSourceDiscoveryProvider();
const ignoredUrls: SourceIntakeResult["ignoredUrls"] = [];

// source discoveryは補助入力。失敗時もrunを止めず、ignoredUrlsへ理由を残す。
let discoveredUrls: string[] = [];
if (discoveryProvider.id !== "disabled") {
const discovery = await discoveryProvider.discoverSources({
Expand All @@ -33,6 +34,8 @@ export async function buildSourceIntakeFromQuestion(
}
}

// manual > topic中URL > discovery候補を同一パイプラインへ流し、
// URL正規化/安全判定/重複排除を一箇所で適用する。
const merged = [
...manualUrls.map((url) => ({ url, origin: "manual_url" as const })),
...topicUrls.map((url) => ({ url, origin: "topic_url" as const })),
Expand All @@ -57,6 +60,7 @@ export async function buildSourceIntakeFromQuestion(
ignoredUrls.push({ url: item.url, reason: result.errorMessage });
continue;
}
// 最終URLではなくnormalized URLでdedupeし、同一記事の追跡パラメータ差分を吸収する。
if (seen.has(result.normalizedUrl)) continue;
seen.add(result.normalizedUrl);

Expand Down
4 changes: 4 additions & 0 deletions src/types/answer-graph-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export type GeneratedEvidenceBundle = {
};

/** Persistable result of a successful generation step (DB writer consumes this). */
/**
* provider出力を永続化用へ正規化した中間payload。
* graphJsonは描画スナップショットで、claim/source実体は別テーブルへ保存される。
*/
export type GeneratedAnswerGraphPayload = {
answer: {
title: string | null;
Expand Down
1 change: 1 addition & 0 deletions tests/source-intake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { extractUrls } from "@/server/analysis/source-intake/extract-urls";
import { normalizeSourceUrl } from "@/server/analysis/source-url-normalization";
import { isSourceFetchSafe } from "@/server/analysis/source-fetch-safety";

// Source intakeは「抽出→正規化→安全判定」の入口仕様を守るテスト。
describe("source intake basics", () => {
it("extracts multiple http/https URLs and trims trailing punctuation", () => {
const result = extractUrls("See https://Example.com/a, and http://example.org/b?x=1.");
Expand Down
Loading