From 6baced4cef387f2bf2b023477c661a1e71699f6b Mon Sep 17 00:00:00 2001 From: veil-chow-fyaic Date: Tue, 19 May 2026 10:43:02 +0800 Subject: [PATCH 1/4] Fix local stability issues --- frontend/src/main/backend.ts | 18 +++++- .../background/task/screen-monitor-task.ts | 7 ++- frontend/src/main/index.ts | 1 + frontend/src/main/services/AppUpdater.ts | 15 ++++- frontend/src/main/services/Database.ts | 7 +-- frontend/src/main/services/DatabaseService.ts | 14 +---- frontend/src/main/services/dbPath.ts | 16 +++++ .../src/components/ai-toggle-button/index.tsx | 4 +- .../components/modelRadio/model-radio.tsx | 2 +- .../src/services/GlobalEventService.ts | 19 +++++- .../generation/generation_report.py | 59 +++++++++++++++---- .../generation/smart_tip_generator.py | 5 +- opencontext/server/routes/agent_chat.py | 9 ++- 13 files changed, 136 insertions(+), 40 deletions(-) create mode 100644 frontend/src/main/services/dbPath.ts diff --git a/frontend/src/main/backend.ts b/frontend/src/main/backend.ts index fbbdf3ab..2b83226b 100644 --- a/frontend/src/main/backend.ts +++ b/frontend/src/main/backend.ts @@ -15,6 +15,7 @@ import { IpcServerPushChannel } from '@shared/ipc-server-push-channel' let backendLogFile: string | null = null let backendProcess: any = null +let backendOwnedByThisProcess = false let backendPort = 1733 // Dynamic port, starting from 1733 let backendStatus: 'starting' | 'running' | 'stopped' | 'error' = 'stopped' // Backend service status let ensureBackendRunningPromise: Promise | null = null @@ -52,7 +53,7 @@ function isPortAvailable(port: number): Promise { server.once('error', (err: any) => { // Port is occupied or other errors if (err.code === 'EADDRINUSE') { - logToBackendFile(`Port ${port} is in use (EADDRINUSE)`) + logToBackendFile(`Port ${port} is already occupied; checking whether it is an existing MineContext backend`) } else { logToBackendFile(`Port ${port} check error: ${err.code} - ${err.message}`) } @@ -241,6 +242,11 @@ export function logToBackendFile(message) { } export function stopBackendServer() { + if (!backendOwnedByThisProcess) { + logToBackendFile(`Skipping backend stop for reused external service on port ${backendPort}`) + return + } + if (backendProcess) { logToBackendFile('Stopping backend server...') setBackendStatus('stopped') @@ -270,6 +276,7 @@ export function stopBackendServer() { } backendProcess = null + backendOwnedByThisProcess = false logToBackendFile('Backend server stop signal sent') } @@ -384,6 +391,7 @@ export async function ensureBackendRunning(mainWindow: BrowserWindow) { const existingBackend = await findRunningBackendPort(1733, 20) if (existingBackend) { backendPort = existingBackend.port + backendOwnedByThisProcess = false setBackendStatus('running') mainWindow.webContents.send(IpcServerPushChannel.PushGetInitCheckData, existingBackend.healthCheckResult) logToBackendFile(`Reused existing backend service on port ${backendPort}`) @@ -527,6 +535,7 @@ async function startBackendServer(mainWindow: BrowserWindow) { cwd: backendDir, // Change to backend directory before executing env: env }) + backendOwnedByThisProcess = true // On Unix systems, create a new process group if (process.platform !== 'win32' && backendProcess.pid) { @@ -600,6 +609,7 @@ async function startBackendServer(mainWindow: BrowserWindow) { backendProcess.on('close', (code) => { logToBackendFile(`Backend process exited with code ${code}`) setBackendStatus('stopped') + backendOwnedByThisProcess = false if (code !== 0 && !healthCheckStarted) { setBackendStatus('error') reject(new Error(`Backend process exited with code ${code}`)) @@ -680,6 +690,11 @@ export async function startBackendInBackground(mainWindow: BrowserWindow) { export function stopBackendServerSync() { logToBackendFile('Synchronously stopping backend server...') + if (!backendOwnedByThisProcess) { + logToBackendFile(`Skipping sync backend stop for reused external service on port ${backendPort}`) + return + } + if (backendProcess) { try { // 立即发送终止信号 @@ -714,6 +729,7 @@ export function stopBackendServerSync() { } backendProcess = null + backendOwnedByThisProcess = false } // 立即尝试清理端口 diff --git a/frontend/src/main/background/task/screen-monitor-task.ts b/frontend/src/main/background/task/screen-monitor-task.ts index 0b70a6f2..9b429740 100644 --- a/frontend/src/main/background/task/screen-monitor-task.ts +++ b/frontend/src/main/background/task/screen-monitor-task.ts @@ -42,7 +42,7 @@ class ScreenMonitorTask extends ScheduleNextTask { fetchFn: async () => { return await this.getVisibleSourcesUseCache() }, - interval: 3 * 1000, + interval: 3, immediate: true }) logger.info('ScreenMonitorTask initialized') @@ -150,7 +150,10 @@ class ScreenMonitorTask extends ScheduleNextTask { } private async startScreenMonitor() { try { - const visibleSources = this.configCache?.get() + let visibleSources = this.configCache?.get() + if (!visibleSources || visibleSources.length === 0) { + visibleSources = await this.getVisibleSourcesUseCache() + } logger.debug( 'visibleSources', visibleSources?.map((item) => pick(item, ['name', 'type', 'isVisible'])) diff --git a/frontend/src/main/index.ts b/frontend/src/main/index.ts index 4d4edb12..5bdcbe3a 100644 --- a/frontend/src/main/index.ts +++ b/frontend/src/main/index.ts @@ -31,6 +31,7 @@ import { IpcChannel } from '@shared/IpcChannel' import { LatestActivityTask } from './background/task/latest-activity' initLog() +app.setName('MineContext') const logger = getLogger('MainEntry') autoUpdater.logger = logger diff --git a/frontend/src/main/services/AppUpdater.ts b/frontend/src/main/services/AppUpdater.ts index 1067cc9c..22f2b5ea 100644 --- a/frontend/src/main/services/AppUpdater.ts +++ b/frontend/src/main/services/AppUpdater.ts @@ -13,8 +13,7 @@ export default class AppUpdater { private cancellationToken: CancellationToken = new CancellationToken() constructor(mainWindow: BrowserWindow) { autoUpdater.logger = logger - // for dev test - autoUpdater.forceDevUpdateConfig = !app.isPackaged + autoUpdater.forceDevUpdateConfig = false // if (isDev) { // const devConfigPath = path.join(process.cwd(), 'dev-app-update.yml') @@ -47,13 +46,23 @@ export default class AppUpdater { }) } public async checkForUpdates() { + if (!app.isPackaged) { + logger.info('Skip update check in development mode') + return { + currentVersion: app.getVersion(), + updateInfo: null + } + } + try { this.updateCheckResult = await this.autoUpdater.checkForUpdates() if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) { // 如果 autoDownload 为 false,则需要再调用下面的函数触发下 // do not use await, because it will block the return of this function logger.info('downloadUpdate manual by check for updates', this.cancellationToken) - this.autoUpdater.downloadUpdate(this.cancellationToken) + void this.autoUpdater.downloadUpdate(this.cancellationToken).catch((error) => { + logger.error('Failed to download update:', error as Error) + }) } logger.info( `update check result: ${this.updateCheckResult?.isUpdateAvailable}, channel: ${this.autoUpdater.channel}, currentVersion: ${this.autoUpdater.currentVersion}` diff --git a/frontend/src/main/services/Database.ts b/frontend/src/main/services/Database.ts index 38f1bdf0..f26f1164 100644 --- a/frontend/src/main/services/Database.ts +++ b/frontend/src/main/services/Database.ts @@ -5,8 +5,8 @@ import { is } from '@electron-toolkit/utils' import BetterSqlite3, { type Statement, type RunResult, type Database as BetterSqliteDatabase } from 'better-sqlite3' import path from 'path' import fs from 'fs' -import { app } from 'electron' import { getLogger } from '@shared/logger/main' +import { resolveSqliteDbPath } from './dbPath' const logger = getLogger('Database') /** * Represents the result of a query returning a list of items. @@ -67,10 +67,7 @@ export class DB { public static getInstance(dbName: string = 'app.db', dbPath1?: string): DB { if (!this.instance) { - // It is recommended to place the database file in a fixed location, such as the data folder in the project root directory - const dbPath = - dbPath1 || - path.join(!app.isPackaged && is.dev ? 'backend' : app.getPath('userData'), 'persist', 'sqlite', dbName) + const dbPath = dbPath1 || resolveSqliteDbPath(dbName) this.instance = new DB(dbPath) } return this.instance diff --git a/frontend/src/main/services/DatabaseService.ts b/frontend/src/main/services/DatabaseService.ts index b2e2c54f..3cd77c1c 100644 --- a/frontend/src/main/services/DatabaseService.ts +++ b/frontend/src/main/services/DatabaseService.ts @@ -3,15 +3,14 @@ // electron/database.ts - SQLite Database Manager (ES Module Compatible) import Database from 'better-sqlite3' -import path from 'node:path' +import path from 'path' import fs from 'fs' -import { app } from 'electron' import { isValidIsoString, toSqliteDatetime } from '../utils/time' -import { is } from '@electron-toolkit/utils' import { DB } from './Database' import { TODOActivity } from '@interface/db/todo' import { getLogger } from '@shared/logger/main' import { VaultDatabaseService } from './VaultDatabaseService' +import { resolveSqliteDbPath } from './dbPath' const logger = getLogger('DatabaseManager') class DatabaseManager extends VaultDatabaseService { private db: Database.Database | null = null @@ -21,14 +20,7 @@ class DatabaseManager extends VaultDatabaseService { constructor() { super() - // Dynamically get the application path - // this.dbPath = path.join(app.getPath('userData'), "persist", "sqlite", "app.db") - this.dbPath = path.join( - !app.isPackaged && is.dev ? 'backend' : app.getPath('userData'), - 'persist', - 'sqlite', - 'app.db' - ) + this.dbPath = resolveSqliteDbPath() logger.info('📁 Database path:', this.dbPath) } diff --git a/frontend/src/main/services/dbPath.ts b/frontend/src/main/services/dbPath.ts new file mode 100644 index 00000000..64b3620a --- /dev/null +++ b/frontend/src/main/services/dbPath.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 + +import { is } from '@electron-toolkit/utils' +import { app } from 'electron' +import path from 'path' + +export function resolveSqliteDbPath(dbName = 'app.db'): string { + if (app.isPackaged || !is.dev) { + return path.join(app.getPath('userData'), 'persist', 'sqlite', dbName) + } + + const cwd = process.cwd() + const projectRoot = path.basename(cwd) === 'frontend' ? path.dirname(cwd) : cwd + return path.join(projectRoot, 'persist', 'sqlite', dbName) +} diff --git a/frontend/src/renderer/src/components/ai-toggle-button/index.tsx b/frontend/src/renderer/src/components/ai-toggle-button/index.tsx index b6cad022..a65cf2be 100644 --- a/frontend/src/renderer/src/components/ai-toggle-button/index.tsx +++ b/frontend/src/renderer/src/components/ai-toggle-button/index.tsx @@ -17,7 +17,7 @@ const AIToggleButton: React.FC = ({ onClick, isActive = fal icon={ isActive ? ( - + = ({ onClick, isActive = fal ) : ( - + {
{ModelInfoList?.map((item) => { return ( -
+
{ diff --git a/frontend/src/renderer/src/services/GlobalEventService.ts b/frontend/src/renderer/src/services/GlobalEventService.ts index c7e083f6..c03b2765 100644 --- a/frontend/src/renderer/src/services/GlobalEventService.ts +++ b/frontend/src/renderer/src/services/GlobalEventService.ts @@ -10,6 +10,7 @@ import { PushDataTypes } from '@renderer/constant/feed' import { getLogger } from '@shared/logger/renderer' const logger = getLogger('GlobalEventService') const NORMAL_POLLING_INTERVAL = 30 * 1000 // Normal: 30 seconds +const MAX_NOTIFICATION_MESSAGE_LENGTH = 180 class GlobalEventService { private static instance: GlobalEventService @@ -84,7 +85,7 @@ class GlobalEventService { id: `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, type: this.mapEventTypeToNotificationType(event.type), title: this.getEventTitle(event), - message: removeMarkdownSymbols(event.data.title || '有新的事件通知'), + message: this.getEventMessage(event), timestamp: Date.now(), source: 'assistant', // Can be set based on the event source channel: 'in-app', @@ -121,6 +122,22 @@ class GlobalEventService { } return titleMap[event.type] || '新通知' } + + private getEventMessage(event: any): string { + const rawMessage = + event.type === PushDataTypes.TIP_GENERATED + ? event.data?.content || event.data?.title + : event.data?.title || event.data?.content + return this.compactNotificationMessage(rawMessage || '有新的事件通知') + } + + private compactNotificationMessage(message: string): string { + const plainText = removeMarkdownSymbols(String(message)).replace(/\s+/g, ' ').trim() + if (plainText.length <= MAX_NOTIFICATION_MESSAGE_LENGTH) { + return plainText + } + return `${plainText.slice(0, MAX_NOTIFICATION_MESSAGE_LENGTH - 1)}…` + } } export default GlobalEventService diff --git a/opencontext/context_consumption/generation/generation_report.py b/opencontext/context_consumption/generation/generation_report.py index 08ff0d1b..c759d309 100644 --- a/opencontext/context_consumption/generation/generation_report.py +++ b/opencontext/context_consumption/generation/generation_report.py @@ -9,6 +9,7 @@ import datetime import json +import re from typing import Any, Dict, List, Optional from opencontext.config.global_config import get_prompt_group @@ -51,27 +52,65 @@ async def generate_report(self, start_time: int, end_time: int) -> str: from opencontext.models.enums import VaultType from opencontext.storage.global_storage import get_storage - now = datetime.datetime.now() - report_id = get_storage().insert_vaults( - title=f"Daily Report - {now.strftime('%Y-%m-%d')}", - summary="", - content=result, - document_type=VaultType.DAILY_REPORT.value, - ) + report_date = datetime.datetime.fromtimestamp(start_time).date() + title = f"Daily Report - {report_date.strftime('%Y-%m-%d')}" + normalized_result = self._normalize_report_content(result, report_date) + storage = get_storage() + existing_report = self._find_existing_daily_report(title) + if existing_report: + report_id = existing_report["id"] + storage.update_vault( + vault_id=report_id, + title=title, + summary="", + content=normalized_result, + ) + else: + report_id = storage.insert_vaults( + title=title, + summary="", + content=normalized_result, + document_type=VaultType.DAILY_REPORT.value, + ) publish_event( event_type=EventType.DAILY_SUMMARY_GENERATED, data={ "doc_id": str(report_id), "doc_type": "vaults", - "title": f"Daily Report - {now.strftime('%Y-%m-%d')}", - "content": result, + "title": title, + "content": normalized_result, }, ) - return result + return normalized_result except Exception as e: logger.exception(f"Error generating activity report: {e}") return f"Error generating activity report: {str(e)}" + def _find_existing_daily_report(self, title: str) -> Optional[Dict[str, Any]]: + reports = get_storage().get_reports(limit=200, offset=0) + for report in reports: + if report.get("document_type") == "DailyReport" and report.get("title") == title: + return report + return None + + def _normalize_report_content(self, content: str, report_date: datetime.date) -> str: + normalized = content.strip() + fence_match = re.fullmatch(r"```(?:markdown)?\s*\n([\s\S]*?)\n```", normalized) + if fence_match: + normalized = fence_match.group(1).strip() + + heading = f"# 日报 - {report_date.strftime('%Y年%m月%d日')}" + if re.match(r"^#\s*日报\s*-\s*\d{4}年\d{2}月\d{2}日", normalized): + normalized = re.sub( + r"^#\s*日报\s*-\s*\d{4}年\d{2}月\d{2}日.*$", + heading, + normalized, + count=1, + flags=re.MULTILINE, + ) + elif normalized: + normalized = f"{heading}\n\n{normalized}" + return normalized async def _process_chunks_concurrently(self, start_time: int, end_time: int) -> list: """Process all time chunks concurrently.""" diff --git a/opencontext/context_consumption/generation/smart_tip_generator.py b/opencontext/context_consumption/generation/smart_tip_generator.py index 852c201c..ecddf5b4 100644 --- a/opencontext/context_consumption/generation/smart_tip_generator.py +++ b/opencontext/context_consumption/generation/smart_tip_generator.py @@ -24,6 +24,7 @@ from opencontext.utils.logging_utils import get_logger logger = get_logger(__name__) +SMART_TIP_TITLE = "智能提醒" @dataclass @@ -76,11 +77,11 @@ def generate_smart_tip(self, start_time: int, end_time: int) -> Optional[str]: data={ "doc_id": str(tip_id), "doc_type": "tips", - "title": "intelligence reminder", + "title": SMART_TIP_TITLE, "content": tip_content, }, ) - return {"doc_id": str(tip_id), "title": "intelligence reminder", "content": tip_content} + return {"doc_id": str(tip_id), "title": SMART_TIP_TITLE, "content": tip_content} except Exception as e: logger.exception(f"Failed to generate smart tip: {e}") diff --git a/opencontext/server/routes/agent_chat.py b/opencontext/server/routes/agent_chat.py index f4e6c30d..998ff469 100644 --- a/opencontext/server/routes/agent_chat.py +++ b/opencontext/server/routes/agent_chat.py @@ -45,6 +45,11 @@ def get_agent(): return agent_instance +def create_request_agent(enable_streaming: bool): + """Create an isolated agent so concurrent chats do not share one event queue.""" + return ContextAgent(enable_streaming=enable_streaming) + + # Request models class ChatRequest(BaseModel): """Chat request""" @@ -81,7 +86,7 @@ class ChatResponse(BaseModel): async def chat(request: ChatRequest, _auth: str = auth_dependency) -> ChatResponse: """Intelligent chat interface (non-streaming)""" try: - agent = get_agent() + agent = create_request_agent(enable_streaming=False) # Generate session_id if not request.session_id: @@ -125,7 +130,7 @@ async def generate(): storage = None try: - agent = get_agent() + agent = create_request_agent(enable_streaming=True) storage = get_storage() if not request.session_id: From 75f055523589d6d31302b26495fc1f4bc40921cd Mon Sep 17 00:00:00 2001 From: veil-chow-fyaic Date: Tue, 19 May 2026 11:28:59 +0800 Subject: [PATCH 2/4] Harden prompts and extracted data handling --- opencontext/config/global_config.py | 13 +++++++++++-- opencontext/config/prompt_manager.py | 14 ++++++++++++-- opencontext/models/context.py | 26 +++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/opencontext/config/global_config.py b/opencontext/config/global_config.py index 2bcd5d8a..a10c0e2c 100644 --- a/opencontext/config/global_config.py +++ b/opencontext/config/global_config.py @@ -137,7 +137,11 @@ def _init_prompt_manager(self) -> bool: return False try: - self._prompt_manager = PromptManager(absolute_prompts_path) + user_setting_path = config.get("user_setting_path") + user_prompts_dir = os.path.dirname(user_setting_path) if user_setting_path else None + self._prompt_manager = PromptManager( + absolute_prompts_path, user_prompts_dir=user_prompts_dir + ) self._prompt_path = absolute_prompts_path self._language = language logger.info(f"Prompts loaded from: {self._prompt_path} (language: {language})") @@ -221,7 +225,12 @@ def set_language(self, language: str) -> bool: logger.error(f"Prompt file not found: {absolute_prompts_path}") return False - self._prompt_manager = PromptManager(absolute_prompts_path) + config = self._config_manager.get_config() if self._config_manager else {} + user_setting_path = config.get("user_setting_path") if config else None + user_prompts_dir = os.path.dirname(user_setting_path) if user_setting_path else None + self._prompt_manager = PromptManager( + absolute_prompts_path, user_prompts_dir=user_prompts_dir + ) self._prompt_path = absolute_prompts_path logger.info(f"Prompts reloaded from: {self._prompt_path} (language: {language})") diff --git a/opencontext/config/prompt_manager.py b/opencontext/config/prompt_manager.py index 8dd94215..5fa27fca 100644 --- a/opencontext/config/prompt_manager.py +++ b/opencontext/config/prompt_manager.py @@ -15,9 +15,10 @@ class PromptManager: - def __init__(self, prompt_config_path: str = None): + def __init__(self, prompt_config_path: str = None, user_prompts_dir: str = None): self.prompts = {} self.prompt_config_path = prompt_config_path + self.user_prompts_dir = user_prompts_dir if prompt_config_path and os.path.exists(prompt_config_path): with open(prompt_config_path, "r", encoding="utf-8") as f: self.prompts = yaml.safe_load(f) @@ -37,12 +38,21 @@ def get_prompt(self, name: str, default: str = None) -> str: return value if isinstance(value, str) else default def get_prompt_group(self, name: str) -> Dict[str, str]: + prompt_aliases = { + "processing.extraction.screenshot_analyze": ( + "processing.extraction.screenshot_contextual_batch" + ) + } keys = name.split(".") value = self.prompts for key in keys: if isinstance(value, dict) and key in value: value = value[key] else: + alias = prompt_aliases.get(name) + if alias: + logger.warning(f"Prompt group '{name}' not found, trying alias '{alias}'.") + return self.get_prompt_group(alias) logger.warning(f"Prompt group '{name}' not found.") return {} return value if isinstance(value, dict) else {} @@ -68,7 +78,7 @@ def get_user_prompts_path(self) -> str | None: base_name = os.path.basename(self.prompt_config_path) if "_" in base_name: lang = base_name.split("_")[1].split(".")[0] - dir_name = os.path.dirname(self.prompt_config_path) + dir_name = self.user_prompts_dir or os.path.dirname(self.prompt_config_path) return os.path.join(dir_name, f"user_prompts_{lang}.yaml") return None diff --git a/opencontext/models/context.py b/opencontext/models/context.py index eb931f0a..fc9aae08 100755 --- a/opencontext/models/context.py +++ b/opencontext/models/context.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from opencontext.utils.logging_utils import get_logger @@ -67,6 +67,30 @@ class ExtractedData(BaseModel): confidence: int = 0 # confidence importance: int = 0 # importance + @field_validator("title", "summary", mode="before") + @classmethod + def _coerce_text(cls, value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, list): + return "\n".join(cls._coerce_text(item) or "" for item in value).strip() + if isinstance(value, dict): + return json.dumps(value, ensure_ascii=False) + return str(value) + + @field_validator("keywords", "entities", mode="before") + @classmethod + def _coerce_string_list(cls, value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [cls._coerce_text(item) or "" for item in value if item is not None] + return [cls._coerce_text(value) or ""] + def to_dict(self) -> Dict[str, Any]: """Convert model to dictionary""" return self.model_dump(exclude_none=True) From 02136678f23c980cfd46f3a1783ee69bdeed9ca2 Mon Sep 17 00:00:00 2001 From: veil-chow-fyaic Date: Tue, 19 May 2026 11:39:14 +0800 Subject: [PATCH 3/4] Add regression tests for stability fixes --- tests/test_context_models.py | 24 ++++++++++++++++ tests/test_prompt_manager.py | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/test_context_models.py create mode 100644 tests/test_prompt_manager.py diff --git a/tests/test_context_models.py b/tests/test_context_models.py new file mode 100644 index 00000000..d4a2f282 --- /dev/null +++ b/tests/test_context_models.py @@ -0,0 +1,24 @@ +import unittest + +from opencontext.models.context import ExtractedData +from opencontext.models.enums import ContextType + + +class ExtractedDataTest(unittest.TestCase): + def test_coerces_non_string_summary_and_title(self): + extracted = ExtractedData( + title={"text": "Dashboard"}, + summary=["Step 1: open app", "Step 2: start recording"], + keywords=["recording", {"kind": "screen"}], + entities="MineContext", + context_type=ContextType.ACTIVITY_CONTEXT, + ) + + self.assertEqual(extracted.title, '{"text": "Dashboard"}') + self.assertEqual(extracted.summary, "Step 1: open app\nStep 2: start recording") + self.assertEqual(extracted.keywords, ["recording", '{"kind": "screen"}']) + self.assertEqual(extracted.entities, ["MineContext"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompt_manager.py b/tests/test_prompt_manager.py new file mode 100644 index 00000000..09b92f02 --- /dev/null +++ b/tests/test_prompt_manager.py @@ -0,0 +1,56 @@ +import os +import tempfile +import unittest + +import yaml + +from opencontext.config.prompt_manager import PromptManager + + +class PromptManagerTest(unittest.TestCase): + def test_user_prompts_are_saved_to_configured_user_directory(self): + with tempfile.TemporaryDirectory() as base_dir: + bundled_config_dir = os.path.join(base_dir, "readonly_config") + user_config_dir = os.path.join(base_dir, "user_config") + os.makedirs(bundled_config_dir) + + prompt_path = os.path.join(bundled_config_dir, "prompts_zh.yaml") + with open(prompt_path, "w", encoding="utf-8") as f: + yaml.safe_dump({"generation": {"example": {"system": "s", "user": "u"}}}, f) + + manager = PromptManager(prompt_path, user_prompts_dir=user_config_dir) + + self.assertTrue(manager.save_prompts({"generation": {"example": {"system": "new"}}})) + self.assertTrue(os.path.exists(os.path.join(user_config_dir, "user_prompts_zh.yaml"))) + self.assertFalse( + os.path.exists(os.path.join(bundled_config_dir, "user_prompts_zh.yaml")) + ) + + def test_screenshot_analyze_falls_back_to_legacy_prompt_name(self): + with tempfile.TemporaryDirectory() as base_dir: + prompt_path = os.path.join(base_dir, "prompts_en.yaml") + with open(prompt_path, "w", encoding="utf-8") as f: + yaml.safe_dump( + { + "processing": { + "extraction": { + "screenshot_contextual_batch": { + "system": "legacy system", + "user": "legacy user", + } + } + } + }, + f, + ) + + manager = PromptManager(prompt_path) + + self.assertEqual( + manager.get_prompt_group("processing.extraction.screenshot_analyze"), + {"system": "legacy system", "user": "legacy user"}, + ) + + +if __name__ == "__main__": + unittest.main() From 19dbd94ad046b6ba531153e2aeed66a0cb1053d7 Mon Sep 17 00:00:00 2001 From: veil-chow-fyaic Date: Tue, 19 May 2026 11:49:23 +0800 Subject: [PATCH 4/4] Fix daily report normalization regression --- .../generation/generation_report.py | 2 + tests/test_generation_report.py | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/test_generation_report.py diff --git a/opencontext/context_consumption/generation/generation_report.py b/opencontext/context_consumption/generation/generation_report.py index c759d309..310b6d6d 100644 --- a/opencontext/context_consumption/generation/generation_report.py +++ b/opencontext/context_consumption/generation/generation_report.py @@ -87,6 +87,8 @@ async def generate_report(self, start_time: int, end_time: int) -> str: return f"Error generating activity report: {str(e)}" def _find_existing_daily_report(self, title: str) -> Optional[Dict[str, Any]]: + from opencontext.storage.global_storage import get_storage + reports = get_storage().get_reports(limit=200, offset=0) for report in reports: if report.get("document_type") == "DailyReport" and report.get("title") == title: diff --git a/tests/test_generation_report.py b/tests/test_generation_report.py new file mode 100644 index 00000000..6c789d31 --- /dev/null +++ b/tests/test_generation_report.py @@ -0,0 +1,69 @@ +import datetime +import importlib.util +from pathlib import Path +import sys +import types +import unittest + +fake_vlm_client = types.ModuleType("opencontext.llm.global_vlm_client") + + +async def _fake_generate_with_messages_async(*_args, **_kwargs): + return "" + + +fake_vlm_client.generate_with_messages_async = _fake_generate_with_messages_async +fake_vlm_client.generate_with_messages = lambda *_args, **_kwargs: "" +sys.modules.setdefault("opencontext.llm.global_vlm_client", fake_vlm_client) + +fake_debug_helper = types.ModuleType("opencontext.context_consumption.generation.debug_helper") + + +class _FakeDebugHelper: + pass + + +fake_debug_helper.DebugHelper = _FakeDebugHelper +sys.modules.setdefault("opencontext.context_consumption.generation.debug_helper", fake_debug_helper) + +fake_tool_definitions = types.ModuleType("opencontext.tools.tool_definitions") +fake_tool_definitions.ALL_TOOL_DEFINITIONS = [] +sys.modules.setdefault("opencontext.tools.tool_definitions", fake_tool_definitions) + +fake_tools_executor = types.ModuleType("opencontext.tools.tools_executor") + + +class _FakeToolsExecutor: + pass + + +fake_tools_executor.ToolsExecutor = _FakeToolsExecutor +sys.modules.setdefault("opencontext.tools.tools_executor", fake_tools_executor) + +module_path = ( + Path(__file__).resolve().parents[1] + / "opencontext" + / "context_consumption" + / "generation" + / "generation_report.py" +) +spec = importlib.util.spec_from_file_location("generation_report_for_test", module_path) +generation_report_module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(generation_report_module) +ReportGenerator = generation_report_module.ReportGenerator + + +class ReportGeneratorTest(unittest.TestCase): + def test_normalizes_fenced_report_with_start_date_heading(self): + generator = ReportGenerator.__new__(ReportGenerator) + normalized = generator._normalize_report_content( + "```markdown\n# 日报 - 2026年05月18日\n\ncontent\n```", + datetime.date(2026, 5, 19), + ) + + self.assertEqual(normalized, "# 日报 - 2026年05月19日\n\ncontent") + + +if __name__ == "__main__": + unittest.main()