Coverage for src/refinire/core/prompt_store.py: 80%
234 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 15:40 +0900
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 15:40 +0900
1"""
2PromptStore class for managing prompts with multilingual support
3プロンプトの多言語サポート付き管理クラス
4"""
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
15from .llm import get_llm
17# Supported languages
18LanguageCode = Literal["ja", "en"]
19SUPPORTED_LANGUAGES: Set[LanguageCode] = {"ja", "en"}
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)
34 def __str__(self) -> str:
35 """Return the prompt content when used as string"""
36 return self.content
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
50def get_default_storage_dir() -> Path:
51 """
52 Get the default storage directory for Refinire
53 Refinireのデフォルト保存ディレクトリを取得
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"
65def detect_system_language() -> LanguageCode:
66 """
67 Detect system language from environment variables or OS settings.
68 環境変数やOS設定からシステム言語を検出します。
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"
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
87 return "en"
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)
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()
107 # Return content in requested language if available
108 if language in self.content:
109 return self.content[language]
111 # Fallback to any available language
112 if self.content:
113 return next(iter(self.content.values()))
115 return ""
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 }
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"]))
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 )
145class PromptStore:
146 """
147 Store and manage prompts with multilingual support using SQLite
148 SQLiteを使用した多言語対応のプロンプト保存・管理クラス
150 Prompts are identified by name and tag combination
151 プロンプトは名前とタグの組み合わせで識別されます
153 All methods are class methods that use an internal singleton instance
154 全てのメソッドは内部シングルトンインスタンスを使用するクラスメソッドです
155 """
157 _instance: Optional['PromptStore'] = None
158 _storage_dir: Optional[Path] = None
160 def __init__(self, storage_dir: Optional[Path] = None):
161 """
162 Initialize PromptStore with SQLite database
163 SQLiteデータベースでPromptStoreを初期化
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()
171 self.storage_dir = storage_dir
172 self.db_path = storage_dir / "prompts.db"
174 # Create directory if it doesn't exist
175 storage_dir.mkdir(parents=True, exist_ok=True)
177 # Initialize database
178 self._init_database()
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()
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
194 return cls._instance
196 @classmethod
197 def set_storage_dir(cls, storage_dir: Path):
198 """
199 Set the storage directory for all operations
200 全ての操作用の保存ディレクトリを設定
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
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()
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
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
249 Returns:
250 StoredPrompt object
251 """
252 instance = cls._get_instance(storage_dir)
254 if language is None:
255 language = detect_system_language()
257 now = datetime.now().isoformat()
259 with sqlite3.connect(instance.db_path) as conn:
260 cursor = conn.cursor()
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))
268 existing = cursor.fetchone()
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
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
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
294 conn.commit()
296 # Auto-translate if requested
297 if auto_translate:
298 instance._translate_and_update(name, tag, language, content)
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))
306 row = cursor.fetchone()
307 content_en, content_ja, created_at, updated_at = row
309 content_dict = {}
310 if content_en:
311 content_dict["en"] = content_en
312 if content_ja:
313 content_dict["ja"] = content_ja
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 )
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
329 Args:
330 name: Prompt name
331 tag: Specific tag to identify the prompt
332 storage_dir: Storage directory override
334 Returns:
335 StoredPrompt object or None if not found
336 """
337 instance = cls._get_instance(storage_dir)
339 with sqlite3.connect(instance.db_path) as conn:
340 cursor = conn.cursor()
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()
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
367 if tag is not None:
368 row = cursor.fetchone()
370 if not row:
371 return None
373 name, tag, content_en, content_ja, created_at, updated_at = row
375 content_dict = {}
376 if content_en:
377 content_dict["en"] = content_en
378 if content_ja:
379 content_dict["ja"] = content_ja
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 )
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 指定された名前のプロンプトを全てリスト
395 Args:
396 name: If provided, only return prompts with this name
397 storage_dir: Storage directory override
399 Returns:
400 List of StoredPrompt objects
401 """
402 instance = cls._get_instance(storage_dir)
404 with sqlite3.connect(instance.db_path) as conn:
405 cursor = conn.cursor()
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 """)
420 prompts = []
421 for row in cursor.fetchall():
422 name, tag, content_en, content_ja, created_at, updated_at = row
424 content_dict = {}
425 if content_en:
426 content_dict["en"] = content_en
427 if content_ja:
428 content_dict["ja"] = content_ja
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 ))
438 return prompts
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 名前とタグでプロンプトを削除
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
452 Returns:
453 Number of prompts deleted
454 """
455 instance = cls._get_instance(storage_dir)
457 with sqlite3.connect(instance.db_path) as conn:
458 cursor = conn.cursor()
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,))
469 deleted_count = cursor.rowcount
470 conn.commit()
472 return deleted_count
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 トレース用のメタデータ付きでプロンプトを取得
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
492 Returns:
493 PromptReference object with metadata or None if not found
494 """
495 instance = cls._get_instance(storage_dir)
497 if language is None:
498 language = detect_system_language()
500 with sqlite3.connect(instance.db_path) as conn:
501 cursor = conn.cursor()
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,))
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
531 if tag is not None:
532 row = cursor.fetchone()
534 if not row:
535 return None
537 content_en, content_ja = row
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
551 return PromptReference(
552 content=content,
553 name=name,
554 tag=tag,
555 language=language
556 )
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"
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.
577English prompt:
578{source_content}
580Japanese translation:"""
581 elif source_language == "ja" and target_language == "en":
582 translation_prompt = f"""次の日本語のプロンプトを英語に翻訳してください。
583技術的な意味と意図を正確に保持してください。
584プレースホルダーや変数はそのまま維持してください。
586日本語プロンプト:
587{source_content}
589英語翻訳:"""
590 else:
591 return
593 # Get translation from LLM
594 try:
595 llm = get_llm()
596 response = llm.agent.run(translation_prompt)
598 if hasattr(response, 'messages') and response.messages:
599 translated_content = response.messages[-1].text.strip()
601 # Update database with translation
602 with sqlite3.connect(self.db_path) as conn:
603 cursor = conn.cursor()
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))
616 conn.commit()
617 except Exception as e:
618 # Translation failed, but don't crash
619 print(f"Translation failed: {e}")
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()の短縮エイリアス - プロンプト取得用の便利関数
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 / ストレージディレクトリの上書き
638 Returns:
639 PromptReference object with metadata or None if not found
640 メタデータ付きPromptReferenceオブジェクト、見つからない場合はNone
642 Example:
643 # Long form / 通常の書き方
644 prompt = PromptStore.get("greeting", tag="formal", language="en")
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)