Coverage for src/refinire/core/prompt_store.py: 23%

234 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-15 18:51 +0900

1""" 

2PromptStore class for managing prompts with multilingual support 

3プロンプトの多言語サポート付き管理クラス 

4""" 

5 

6import os 

7import locale 

8import sqlite3 

9from typing import Dict, List, Optional, Set, Literal 

10from dataclasses import dataclass, field 

11from datetime import datetime 

12import json 

13from pathlib import Path 

14 

15from .llm import get_llm 

16 

17# Supported languages 

18LanguageCode = Literal["ja", "en"] 

19SUPPORTED_LANGUAGES: Set[LanguageCode] = {"ja", "en"} 

20 

21 

22@dataclass 

23class PromptReference: 

24 """ 

25 A prompt content with metadata for tracing 

26 トレース用のメタデータ付きプロンプトコンテンツ 

27 """ 

28 content: str 

29 name: str 

30 tag: Optional[str] = None 

31 language: LanguageCode = "en" 

32 retrieved_at: datetime = field(default_factory=datetime.now) 

33 

34 def __str__(self) -> str: 

35 """Return the prompt content when used as string""" 

36 return self.content 

37 

38 def get_metadata(self) -> Dict[str, str]: 

39 """Get metadata for tracing""" 

40 metadata = { 

41 "prompt_name": self.name, 

42 "prompt_language": self.language, 

43 "retrieved_at": self.retrieved_at.isoformat() 

44 } 

45 if self.tag: 

46 metadata["prompt_tag"] = self.tag 

47 return metadata 

48 

49 

50def get_default_storage_dir() -> Path: 

51 """ 

52 Get the default storage directory for Refinire 

53 Refinireのデフォルト保存ディレクトリを取得 

54  

55 Returns: 

56 Path to storage directory (REFINIRE_DIR env var or ~/.refinire) 

57 """ 

58 refinire_dir = os.environ.get("REFINIRE_DIR") 

59 if refinire_dir: 

60 return Path(refinire_dir) 

61 else: 

62 return Path.home() / ".refinire" 

63 

64 

65def detect_system_language() -> LanguageCode: 

66 """ 

67 Detect system language from environment variables or OS settings. 

68 環境変数やOS設定からシステム言語を検出します。 

69  

70 Returns: 

71 'ja' for Japanese, 'en' for others 

72 """ 

73 # Check environment variables commonly used for locale 

74 for env_var in ("LANG", "LC_ALL", "LC_MESSAGES"): 

75 lang_val = os.environ.get(env_var, "") 

76 if lang_val.lower().startswith("ja"): 

77 return "ja" 

78 

79 # Fallback to system locale 

80 try: 

81 sys_loc = locale.getdefaultlocale()[0] or "" 

82 if sys_loc.lower().startswith("ja"): 

83 return "ja" 

84 except: 

85 pass 

86 

87 return "en" 

88 

89 

90@dataclass 

91class StoredPrompt: 

92 """ 

93 A prompt with metadata for storage 

94 保存用のメタデータ付きプロンプト 

95 """ 

96 name: str 

97 content: Dict[LanguageCode, str] # Language -> prompt content 

98 tag: Optional[str] = None # Single tag for categorization 

99 created_at: datetime = field(default_factory=datetime.now) 

100 updated_at: datetime = field(default_factory=datetime.now) 

101 

102 def get_content(self, language: Optional[LanguageCode] = None) -> str: 

103 """Get prompt content in specified language""" 

104 if language is None: 

105 language = detect_system_language() 

106 

107 # Return content in requested language if available 

108 if language in self.content: 

109 return self.content[language] 

110 

111 # Fallback to any available language 

112 if self.content: 

113 return next(iter(self.content.values())) 

114 

115 return "" 

116 

117 def to_dict(self) -> Dict: 

118 """Convert to dictionary for serialization""" 

119 return { 

120 "name": self.name, 

121 "content": self.content, 

122 "tag": self.tag, 

123 "created_at": self.created_at.isoformat(), 

124 "updated_at": self.updated_at.isoformat() 

125 } 

126 

127 @classmethod 

128 def from_dict(cls, data: Dict) -> "StoredPrompt": 

129 """Create from dictionary""" 

130 # Handle backward compatibility for old format with tags 

131 tag = data.get("tag") 

132 if tag is None and "tags" in data and data["tags"]: 

133 # Convert old format: use first tag 

134 tag = data["tags"][0] if isinstance(data["tags"], list) else next(iter(data["tags"])) 

135 

136 return cls( 

137 name=data["name"], 

138 content=data["content"], 

139 tag=tag, 

140 created_at=datetime.fromisoformat(data["created_at"]), 

141 updated_at=datetime.fromisoformat(data["updated_at"]) 

142 ) 

143 

144 

145class PromptStore: 

146 """ 

147 Store and manage prompts with multilingual support using SQLite 

148 SQLiteを使用した多言語対応のプロンプト保存・管理クラス 

149  

150 Prompts are identified by name and tag combination 

151 プロンプトは名前とタグの組み合わせで識別されます 

152  

153 All methods are class methods that use an internal singleton instance 

154 全てのメソッドは内部シングルトンインスタンスを使用するクラスメソッドです 

155 """ 

156 

157 _instance: Optional['PromptStore'] = None 

158 _storage_dir: Optional[Path] = None 

159 

160 def __init__(self, storage_dir: Optional[Path] = None): 

161 """ 

162 Initialize PromptStore with SQLite database 

163 SQLiteデータベースでPromptStoreを初期化 

164  

165 Args: 

166 storage_dir: Directory to store database. If None, uses default directory. 

167 """ 

168 if storage_dir is None: 

169 storage_dir = get_default_storage_dir() 

170 

171 self.storage_dir = storage_dir 

172 self.db_path = storage_dir / "prompts.db" 

173 

174 # Create directory if it doesn't exist 

175 storage_dir.mkdir(parents=True, exist_ok=True) 

176 

177 # Initialize database 

178 self._init_database() 

179 

180 @classmethod 

181 def _get_instance(cls, storage_dir: Optional[Path] = None) -> 'PromptStore': 

182 """ 

183 Get or create singleton instance 

184 シングルトンインスタンスを取得または作成 

185 """ 

186 if storage_dir is None: 

187 storage_dir = get_default_storage_dir() 

188 

189 # Create new instance if none exists or storage directory changed 

190 if cls._instance is None or cls._storage_dir != storage_dir: 

191 cls._instance = cls(storage_dir) 

192 cls._storage_dir = storage_dir 

193 

194 return cls._instance 

195 

196 @classmethod 

197 def set_storage_dir(cls, storage_dir: Path): 

198 """ 

199 Set the storage directory for all operations 

200 全ての操作用の保存ディレクトリを設定 

201  

202 Args: 

203 storage_dir: Directory to store database 

204 """ 

205 cls._instance = None # Force recreation with new directory 

206 cls._storage_dir = storage_dir 

207 

208 def _init_database(self): 

209 """ 

210 Initialize SQLite database and create tables 

211 SQLiteデータベースを初期化してテーブルを作成 

212 """ 

213 with sqlite3.connect(self.db_path) as conn: 

214 cursor = conn.cursor() 

215 cursor.execute(""" 

216 CREATE TABLE IF NOT EXISTS prompts ( 

217 id INTEGER PRIMARY KEY AUTOINCREMENT, 

218 name TEXT NOT NULL, 

219 tag TEXT, 

220 content_en TEXT, 

221 content_ja TEXT, 

222 created_at TEXT NOT NULL, 

223 updated_at TEXT NOT NULL, 

224 UNIQUE(name, tag) 

225 ) 

226 """) 

227 conn.commit() 

228 

229 @classmethod 

230 def store( 

231 cls, 

232 name: str, 

233 content: str, 

234 tag: Optional[str] = None, 

235 language: Optional[LanguageCode] = None, 

236 auto_translate: bool = True, 

237 storage_dir: Optional[Path] = None 

238 ) -> StoredPrompt: 

239 """ 

240 Store a prompt with automatic translation 

241  

242 Args: 

243 name: Unique name for the prompt 

244 content: Prompt content 

245 tag: Optional single tag for categorization 

246 language: Language of the content. If None, detects from system. 

247 auto_translate: Whether to automatically translate to other languages 

248  

249 Returns: 

250 StoredPrompt object 

251 """ 

252 instance = cls._get_instance(storage_dir) 

253 

254 if language is None: 

255 language = detect_system_language() 

256 

257 now = datetime.now().isoformat() 

258 

259 with sqlite3.connect(instance.db_path) as conn: 

260 cursor = conn.cursor() 

261 

262 # Check if prompt exists 

263 cursor.execute(""" 

264 SELECT content_en, content_ja, created_at FROM prompts  

265 WHERE name = ? AND tag IS ? 

266 """, (name, tag)) 

267 

268 existing = cursor.fetchone() 

269 

270 if existing: 

271 # Update existing prompt 

272 content_en, content_ja, created_at = existing 

273 if language == "en": 

274 content_en = content 

275 else: 

276 content_ja = content 

277 

278 cursor.execute(""" 

279 UPDATE prompts  

280 SET content_en = ?, content_ja = ?, updated_at = ? 

281 WHERE name = ? AND tag IS ? 

282 """, (content_en, content_ja, now, name, tag)) 

283 else: 

284 # Insert new prompt 

285 content_en = content if language == "en" else None 

286 content_ja = content if language == "ja" else None 

287 

288 cursor.execute(""" 

289 INSERT INTO prompts (name, tag, content_en, content_ja, created_at, updated_at) 

290 VALUES (?, ?, ?, ?, ?, ?) 

291 """, (name, tag, content_en, content_ja, now, now)) 

292 created_at = now 

293 

294 conn.commit() 

295 

296 # Auto-translate if requested 

297 if auto_translate: 

298 instance._translate_and_update(name, tag, language, content) 

299 

300 # Create and return StoredPrompt object 

301 cursor.execute(""" 

302 SELECT content_en, content_ja, created_at, updated_at FROM prompts  

303 WHERE name = ? AND tag IS ? 

304 """, (name, tag)) 

305 

306 row = cursor.fetchone() 

307 content_en, content_ja, created_at, updated_at = row 

308 

309 content_dict = {} 

310 if content_en: 

311 content_dict["en"] = content_en 

312 if content_ja: 

313 content_dict["ja"] = content_ja 

314 

315 return StoredPrompt( 

316 name=name, 

317 content=content_dict, 

318 tag=tag, 

319 created_at=datetime.fromisoformat(created_at), 

320 updated_at=datetime.fromisoformat(updated_at) 

321 ) 

322 

323 

324 @classmethod 

325 def get_prompt(cls, name: str, tag: Optional[str] = None, storage_dir: Optional[Path] = None) -> Optional[StoredPrompt]: 

326 """ 

327 Get the full StoredPrompt object by name and tag 

328  

329 Args: 

330 name: Prompt name 

331 tag: Specific tag to identify the prompt 

332 storage_dir: Storage directory override 

333  

334 Returns: 

335 StoredPrompt object or None if not found 

336 """ 

337 instance = cls._get_instance(storage_dir) 

338 

339 with sqlite3.connect(instance.db_path) as conn: 

340 cursor = conn.cursor() 

341 

342 if tag is not None: 

343 cursor.execute(""" 

344 SELECT name, tag, content_en, content_ja, created_at, updated_at  

345 FROM prompts WHERE name = ? AND tag = ? 

346 """, (name, tag)) 

347 else: 

348 # First try to find untagged prompt 

349 cursor.execute(""" 

350 SELECT name, tag, content_en, content_ja, created_at, updated_at  

351 FROM prompts WHERE name = ? AND tag IS NULL 

352 """, (name,)) 

353 row = cursor.fetchone() 

354 

355 if not row: 

356 # No untagged prompt, check if there's exactly one with this name 

357 cursor.execute(""" 

358 SELECT name, tag, content_en, content_ja, created_at, updated_at  

359 FROM prompts WHERE name = ? 

360 """, (name,)) 

361 rows = cursor.fetchall() 

362 if len(rows) == 1: 

363 row = rows[0] 

364 else: 

365 return None 

366 

367 if tag is not None: 

368 row = cursor.fetchone() 

369 

370 if not row: 

371 return None 

372 

373 name, tag, content_en, content_ja, created_at, updated_at = row 

374 

375 content_dict = {} 

376 if content_en: 

377 content_dict["en"] = content_en 

378 if content_ja: 

379 content_dict["ja"] = content_ja 

380 

381 return StoredPrompt( 

382 name=name, 

383 content=content_dict, 

384 tag=tag, 

385 created_at=datetime.fromisoformat(created_at), 

386 updated_at=datetime.fromisoformat(updated_at) 

387 ) 

388 

389 @classmethod 

390 def list_prompts(cls, name: Optional[str] = None, storage_dir: Optional[Path] = None) -> List[StoredPrompt]: 

391 """ 

392 List all prompts with the specified name 

393 指定された名前のプロンプトを全てリスト 

394  

395 Args: 

396 name: If provided, only return prompts with this name 

397 storage_dir: Storage directory override 

398  

399 Returns: 

400 List of StoredPrompt objects 

401 """ 

402 instance = cls._get_instance(storage_dir) 

403 

404 with sqlite3.connect(instance.db_path) as conn: 

405 cursor = conn.cursor() 

406 

407 if name: 

408 cursor.execute(""" 

409 SELECT name, tag, content_en, content_ja, created_at, updated_at  

410 FROM prompts WHERE name = ? 

411 ORDER BY name, tag 

412 """, (name,)) 

413 else: 

414 cursor.execute(""" 

415 SELECT name, tag, content_en, content_ja, created_at, updated_at  

416 FROM prompts 

417 ORDER BY name, tag 

418 """) 

419 

420 prompts = [] 

421 for row in cursor.fetchall(): 

422 name, tag, content_en, content_ja, created_at, updated_at = row 

423 

424 content_dict = {} 

425 if content_en: 

426 content_dict["en"] = content_en 

427 if content_ja: 

428 content_dict["ja"] = content_ja 

429 

430 prompts.append(StoredPrompt( 

431 name=name, 

432 content=content_dict, 

433 tag=tag, 

434 created_at=datetime.fromisoformat(created_at), 

435 updated_at=datetime.fromisoformat(updated_at) 

436 )) 

437 

438 return prompts 

439 

440 @classmethod 

441 def delete(cls, name: str, tag: Optional[str] = None, storage_dir: Optional[Path] = None) -> int: 

442 """ 

443 Delete prompts by name and optionally tag 

444 名前とタグでプロンプトを削除 

445  

446 Args: 

447 name: Prompt name to delete 

448 tag: If provided, only delete prompts with this exact tag. 

449 If None, delete all prompts with the given name. 

450 storage_dir: Storage directory override 

451  

452 Returns: 

453 Number of prompts deleted 

454 """ 

455 instance = cls._get_instance(storage_dir) 

456 

457 with sqlite3.connect(instance.db_path) as conn: 

458 cursor = conn.cursor() 

459 

460 if tag is not None: 

461 cursor.execute(""" 

462 DELETE FROM prompts WHERE name = ? AND tag = ? 

463 """, (name, tag)) 

464 else: 

465 cursor.execute(""" 

466 DELETE FROM prompts WHERE name = ? 

467 """, (name,)) 

468 

469 deleted_count = cursor.rowcount 

470 conn.commit() 

471 

472 return deleted_count 

473 

474 @classmethod 

475 def get( 

476 cls, 

477 name: str, 

478 tag: Optional[str] = None, 

479 language: Optional[LanguageCode] = None, 

480 storage_dir: Optional[Path] = None 

481 ) -> Optional[PromptReference]: 

482 """ 

483 Get a prompt with metadata for tracing 

484 トレース用のメタデータ付きでプロンプトを取得 

485  

486 Args: 

487 name: Prompt name 

488 tag: Specific tag to identify the prompt 

489 language: Desired language. If None, uses system language. 

490 storage_dir: Storage directory override 

491  

492 Returns: 

493 PromptReference object with metadata or None if not found 

494 """ 

495 instance = cls._get_instance(storage_dir) 

496 

497 if language is None: 

498 language = detect_system_language() 

499 

500 with sqlite3.connect(instance.db_path) as conn: 

501 cursor = conn.cursor() 

502 

503 if tag is not None: 

504 # Look for exact match with name and tag 

505 cursor.execute(""" 

506 SELECT content_en, content_ja FROM prompts  

507 WHERE name = ? AND tag = ? 

508 """, (name, tag)) 

509 else: 

510 # Look for prompt without tag, or if only one exists with this name 

511 cursor.execute(""" 

512 SELECT content_en, content_ja FROM prompts  

513 WHERE name = ? AND tag IS NULL 

514 """, (name,)) 

515 

516 row = cursor.fetchone() 

517 if not row: 

518 # No untagged prompt, check if there's exactly one with this name 

519 cursor.execute(""" 

520 SELECT content_en, content_ja FROM prompts  

521 WHERE name = ? 

522 """, (name,)) 

523 rows = cursor.fetchall() 

524 if len(rows) == 1: 

525 row = rows[0] 

526 else: 

527 return None 

528 else: 

529 pass # Use the untagged prompt 

530 

531 if tag is not None: 

532 row = cursor.fetchone() 

533 

534 if not row: 

535 return None 

536 

537 content_en, content_ja = row 

538 

539 # Get content in preferred language 

540 if language == "en" and content_en: 

541 content = content_en 

542 elif language == "ja" and content_ja: 

543 content = content_ja 

544 elif content_en: 

545 content = content_en 

546 elif content_ja: 

547 content = content_ja 

548 else: 

549 return None 

550 

551 return PromptReference( 

552 content=content, 

553 name=name, 

554 tag=tag, 

555 language=language 

556 ) 

557 

558 def _translate_and_update( 

559 self, 

560 name: str, 

561 tag: Optional[str], 

562 source_language: LanguageCode, 

563 source_content: str 

564 ): 

565 """ 

566 Translate prompt and update database 

567 プロンプトを翻訳してデータベースを更新 

568 """ 

569 target_language = "ja" if source_language == "en" else "en" 

570 

571 # Create translation prompt 

572 if source_language == "en" and target_language == "ja": 

573 translation_prompt = f"""Translate the following English prompt to Japanese. 

574Keep the technical meaning and intent exactly the same. 

575Maintain any placeholders or variables as-is. 

576 

577English prompt: 

578{source_content} 

579 

580Japanese translation:""" 

581 elif source_language == "ja" and target_language == "en": 

582 translation_prompt = f"""次の日本語のプロンプトを英語に翻訳してください。 

583技術的な意味と意図を正確に保持してください。 

584プレースホルダーや変数はそのまま維持してください。 

585 

586日本語プロンプト: 

587{source_content} 

588 

589英語翻訳:""" 

590 else: 

591 return 

592 

593 # Get translation from LLM 

594 try: 

595 llm = get_llm() 

596 response = llm.agent.run(translation_prompt) 

597 

598 if hasattr(response, 'messages') and response.messages: 

599 translated_content = response.messages[-1].text.strip() 

600 

601 # Update database with translation 

602 with sqlite3.connect(self.db_path) as conn: 

603 cursor = conn.cursor() 

604 

605 if target_language == "en": 

606 cursor.execute(""" 

607 UPDATE prompts SET content_en = ?  

608 WHERE name = ? AND tag IS ? 

609 """, (translated_content, name, tag)) 

610 else: 

611 cursor.execute(""" 

612 UPDATE prompts SET content_ja = ?  

613 WHERE name = ? AND tag IS ? 

614 """, (translated_content, name, tag)) 

615 

616 conn.commit() 

617 except Exception as e: 

618 # Translation failed, but don't crash 

619 print(f"Translation failed: {e}") 

620 

621 

622def P( 

623 name: str, 

624 tag: Optional[str] = None, 

625 language: Optional[LanguageCode] = None, 

626 storage_dir: Optional[Path] = None 

627) -> Optional[PromptReference]: 

628 """ 

629 Short alias for PromptStore.get() - convenient function for prompt retrieval 

630 PromptStore.get()の短縮エイリアス - プロンプト取得用の便利関数 

631  

632 Args: 

633 name: Prompt name / プロンプト名 

634 tag: Specific tag to identify the prompt / プロンプト識別用の特定タグ 

635 language: Desired language. If None, uses system language / 希望言語。Noneの場合はシステム言語を使用 

636 storage_dir: Storage directory override / ストレージディレクトリの上書き 

637  

638 Returns: 

639 PromptReference object with metadata or None if not found 

640 メタデータ付きPromptReferenceオブジェクト、見つからない場合はNone 

641  

642 Example: 

643 # Long form / 通常の書き方 

644 prompt = PromptStore.get("greeting", tag="formal", language="en") 

645  

646 # Short form / 短縮形 

647 prompt = P("greeting", tag="formal", language="en") 

648 prompt = P("greeting") # Uses default tag and system language 

649 """ 

650 return PromptStore.get(name=name, tag=tag, language=language, storage_dir=storage_dir) 

651