feat(perception): 智能感知模块 - event feed, SSE impact analysis, tab registration

This commit is contained in:
2026-05-22 00:42:28 +08:00
parent f9ee644f25
commit 37f7a60b0a
8 changed files with 643 additions and 2 deletions

View File

@@ -0,0 +1,128 @@
const PERCEPTION_API_BASE = '/api/v1';
export type ImpactLevel = 'high' | 'medium' | 'low';
export type EventStatus = 'enacted' | 'draft' | 'consultation';
export type EventSource = 'MIIT' | 'UN-ECE' | 'ISO' | '国标委' | 'EUR-Lex' | 'IATF';
export interface RegulationEvent {
id: string;
source: EventSource;
source_label: string;
standard_code: string;
title: string;
summary: string;
impact_level: ImpactLevel;
published_at: string;
effective_at: string | null;
category: string;
tags: string[];
source_url: string;
status: EventStatus;
}
export interface PerceptionStats {
total: number;
high_impact: number;
medium_impact: number;
low_impact: number;
recent_90d: number;
}
export interface EventListResponse {
events: RegulationEvent[];
total: number;
}
export interface AffectedDoc {
doc_id: string;
doc_name: string;
score: number;
snippet: string;
clause: string;
}
export interface AnalysisSSEMessage {
type: 'sources' | 'content' | 'done' | 'error';
docs?: AffectedDoc[];
text?: string;
}
export async function getPerceptionStats(): Promise<PerceptionStats> {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/stats`);
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
return res.json() as Promise<PerceptionStats>;
}
export async function listEvents(params?: {
source?: string;
impact_level?: string;
limit?: number;
}): Promise<EventListResponse> {
const query = new URLSearchParams();
if (params?.source) query.set('source', params.source);
if (params?.impact_level) query.set('impact_level', params.impact_level);
if (params?.limit) query.set('limit', String(params.limit));
const res = await fetch(`${PERCEPTION_API_BASE}/perception/events?${query.toString()}`);
if (!res.ok) throw new Error(`list events failed: ${res.status}`);
return res.json() as Promise<EventListResponse>;
}
export async function analyzeEvent(
eventId: string,
onMessage: (msg: AnalysisSSEMessage) => void,
onComplete?: () => void,
signal?: AbortSignal,
): Promise<void> {
try {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/events/${eventId}/analyze`, {
method: 'POST',
headers: { Accept: 'text/event-stream' },
signal,
});
if (!res.ok || !res.body) throw new Error(`analyze failed: ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const block of parts) {
if (!block.trim()) continue;
let eventName = 'message';
const dataLines: string[] = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
}
const payload = dataLines.join('\n');
if (!payload) continue;
if (eventName === 'sources') {
try {
const docs = JSON.parse(payload) as AffectedDoc[];
onMessage({ type: 'sources', docs });
} catch { /* ignore */ }
} else if (eventName === 'content') {
onMessage({ type: 'content', text: payload });
} else if (eventName === 'done') {
onMessage({ type: 'done' });
} else if (eventName === 'error') {
onMessage({ type: 'error', text: payload });
}
}
}
if (buffer.trim()) {
// flush remaining
}
onComplete?.();
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
onMessage({ type: 'error', text: err instanceof Error ? err.message : String(err) });
onComplete?.();
}
}