ex-brain 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  import type { ResolvedLLM } from "../settings";
2
2
  import type { TimelineEntry } from "../types";
3
+ import { callLLM, resolveApiKey, isLLMConfigured } from "./llm-client";
4
+ import { jsonrepair } from "jsonrepair";
3
5
 
4
6
  // ---------------------------------------------------------------------------
5
7
  // Types
@@ -50,14 +52,14 @@ export async function extractTimelineEvents(
50
52
  input: TimelineExtractionInput,
51
53
  llm: ResolvedLLM,
52
54
  ): Promise<TimelineExtractionResult> {
53
- const apiKey = resolveApiKey(llm);
54
- if (!apiKey) {
55
+ if (!isLLMConfigured(llm)) {
55
56
  // Fallback: regex-based extraction
56
57
  return fallbackExtract(input);
57
58
  }
58
59
 
59
60
  const prompt = buildExtractionPrompt(input);
60
- const resp = await callLLM(llm, prompt, 2048);
61
+ const systemPrompt = "You are a timeline extraction assistant. Extract events from unstructured text. Always output valid JSON array. Be concise and factual.";
62
+ const resp = await callLLM(llm, prompt, 2048, systemPrompt);
61
63
 
62
64
  if (!resp) {
63
65
  return fallbackExtract(input);
@@ -94,7 +96,8 @@ export async function extractTimelineFromRelation(
94
96
  }
95
97
 
96
98
  const prompt = buildRelationTimelinePrompt(relation, defaultDate);
97
- const resp = await callLLM(llm, prompt, 512);
99
+ const systemPrompt = "You are a timeline extraction assistant. Extract events from relationships. Always output valid JSON array.";
100
+ const resp = await callLLM(llm, prompt, 512, systemPrompt);
98
101
 
99
102
  if (!resp) return null;
100
103
 
@@ -186,50 +189,6 @@ Examples:
186
189
  /no_think`;
187
190
  }
188
191
 
189
- // ---------------------------------------------------------------------------
190
- // LLM Call
191
- // ---------------------------------------------------------------------------
192
-
193
- async function callLLM(llm: ResolvedLLM, prompt: string, maxTokens: number): Promise<string> {
194
- const apiKey = resolveApiKey(llm);
195
- if (!apiKey) return "";
196
-
197
- const body = {
198
- model: llm.model,
199
- messages: [
200
- { role: "system", content: "You are a timeline extraction assistant. Extract events from unstructured text. Always output valid JSON array. Be concise and factual." },
201
- { role: "user", content: prompt },
202
- ],
203
- temperature: 0.1,
204
- max_tokens: maxTokens,
205
- enable_thinking: false,
206
- };
207
-
208
- try {
209
- const resp = await fetch(
210
- llm.baseURL.endsWith("/") ? llm.baseURL + "chat/completions" : llm.baseURL + "/chat/completions",
211
- {
212
- method: "POST",
213
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
214
- body: JSON.stringify(body),
215
- },
216
- );
217
-
218
- if (!resp.ok) {
219
- const text = await resp.text();
220
- console.warn(`[timeline-extractor] LLM call failed (${resp.status}): ${text.slice(0, 200)}`);
221
- return "";
222
- }
223
-
224
- const data = await resp.json();
225
- return data.choices?.[0]?.message?.content?.trim() ?? "";
226
- } catch (error) {
227
- const msg = error instanceof Error ? error.message : String(error);
228
- console.warn(`[timeline-extractor] LLM call error: ${msg}`);
229
- return "";
230
- }
231
- }
232
-
233
192
  // ---------------------------------------------------------------------------
234
193
  // Response Parsing
235
194
  // ---------------------------------------------------------------------------
@@ -239,7 +198,9 @@ function parseExtractionResponse(resp: string, pageSlug: string): TimelineEntry[
239
198
  if (!match) return [];
240
199
 
241
200
  try {
242
- const parsed = JSON.parse(match[0]) as unknown[];
201
+ // Use jsonrepair to fix common LLM JSON issues
202
+ const repaired = jsonrepair(match[0]);
203
+ const parsed = JSON.parse(repaired) as unknown[];
243
204
  const entries: TimelineEntry[] = [];
244
205
 
245
206
  for (const e of parsed) {
@@ -249,12 +210,18 @@ function parseExtractionResponse(resp: string, pageSlug: string): TimelineEntry[
249
210
  const date = normalizeDate(String(entry.date ?? ""));
250
211
  if (!date) continue;
251
212
 
213
+ // Get importance from the response, default to 3
214
+ const importance = typeof entry.importance === "number"
215
+ ? Math.max(1, Math.min(5, Math.round(entry.importance)))
216
+ : 3;
217
+
252
218
  entries.push({
253
219
  pageSlug,
254
220
  date,
255
221
  source: "extracted",
256
222
  summary: String(entry.summary ?? "").slice(0, 120),
257
223
  detail: String(entry.detail ?? ""),
224
+ importance,
258
225
  });
259
226
  }
260
227
 
@@ -344,15 +311,19 @@ function normalizeDate(raw: string, defaultDate?: string): string {
344
311
  const chineseMatch = trimmed.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
345
312
  if (chineseMatch) {
346
313
  const [, year, month, day] = chineseMatch;
347
- return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
314
+ if (year && month && day) {
315
+ return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
316
+ }
348
317
  }
349
318
 
350
319
  // Chinese format without year: 1月15日
351
320
  const chineseNoYearMatch = trimmed.match(/(\d{1,2})月(\d{1,2})日/);
352
321
  if (chineseNoYearMatch && defaultDate) {
353
322
  const [, month, day] = chineseNoYearMatch;
354
- const year = defaultDate.slice(0, 4);
355
- return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
323
+ if (month && day) {
324
+ const year = defaultDate.slice(0, 4);
325
+ return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
326
+ }
356
327
  }
357
328
 
358
329
  // English month names
@@ -374,10 +345,12 @@ function normalizeDate(raw: string, defaultDate?: string): string {
374
345
  const englishMatch = trimmed.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?/i);
375
346
  if (englishMatch) {
376
347
  const [, monthName, day, year] = englishMatch;
377
- const month = monthMap[monthName.toLowerCase().slice(0, 3)];
378
- if (month) {
379
- const finalYear = year || (defaultDate ? defaultDate.slice(0, 4) : new Date().getFullYear().toString());
380
- return `${finalYear}-${month}-${day.padStart(2, "0")}`;
348
+ if (monthName && day) {
349
+ const month = monthMap[monthName.toLowerCase().slice(0, 3)];
350
+ if (month) {
351
+ const finalYear = year || (defaultDate ? defaultDate.slice(0, 4) : new Date().getFullYear().toString());
352
+ return `${finalYear}-${month}-${day.padStart(2, "0")}`;
353
+ }
381
354
  }
382
355
  }
383
356
 
@@ -416,12 +389,6 @@ function normalizeDate(raw: string, defaultDate?: string): string {
416
389
  // Helpers
417
390
  // ---------------------------------------------------------------------------
418
391
 
419
- function resolveApiKey(llm: ResolvedLLM): string {
420
- if (llm.apiKey) return llm.apiKey;
421
- if (llm.apiKeyEnv) return process.env[llm.apiKeyEnv] ?? "";
422
- return "";
423
- }
424
-
425
392
  function deduplicateEntries(entries: TimelineEntry[]): TimelineEntry[] {
426
393
  const seen = new Map<string, TimelineEntry>();
427
394