ryoshimu commited on
Commit
854e13d
·
1 Parent(s): 668c519
Files changed (2) hide show
  1. __pycache__/app.cpython-313.pyc +0 -0
  2. app.py +110 -229
__pycache__/app.cpython-313.pyc CHANGED
Binary files a/__pycache__/app.cpython-313.pyc and b/__pycache__/app.cpython-313.pyc differ
 
app.py CHANGED
@@ -1,261 +1,142 @@
1
  import os
2
- import re
3
- from dataclasses import dataclass
4
- from typing import Dict, Iterable, List, Optional, Tuple
5
 
6
  import gradio as gr
7
- import requests
8
 
9
 
10
- class ToolCallError(RuntimeError):
11
- """リトライを行ってもツール呼び出しが失敗した場合に発生する例外。"""
12
-
13
-
14
- @dataclass
15
- class StorageHit:
16
- """storage_search が返すヒット1件分の情報を保持する構造体。"""
17
-
18
- file_id: Optional[str]
19
- file_name: Optional[str]
20
- chunk_id: Optional[str]
21
- score: Optional[float]
22
- snippet: Optional[str]
23
-
24
-
25
- VECTOR_STORE_ID_RE = re.compile(r"vs_[a-zA-Z0-9]{8,}")
26
-
27
-
28
- class OpenAIStorageClient:
29
- """OpenAI Storage の検索・取得APIを呼び出す最小限のクライアント。"""
30
-
31
- def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: float = 15.0):
32
- """APIキーと各種設定を初期化する。"""
33
- if not api_key:
34
- raise ValueError("api_key is required")
35
- self.api_key = api_key
36
- base = base_url or os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
37
- self.base_url = base.rstrip("/")
38
- self.timeout = timeout
39
-
40
- def _post(self, path: str, payload: Dict) -> Dict:
41
- """与えられたパスにPOSTし、JSONレスポンスを辞書で返す。"""
42
- url = f"{self.base_url}{path}"
43
- headers = {
44
- "Authorization": f"Bearer {self.api_key}",
45
- "Content-Type": "application/json",
46
- }
47
- try:
48
- response = requests.post(url, json=payload, headers=headers, timeout=self.timeout)
49
- except requests.RequestException as exc:
50
- raise ToolCallError(str(exc)) from exc
51
-
52
- if response.status_code >= 400:
53
- raise ToolCallError(f"Tool call failed with status {response.status_code}: {response.text}")
54
-
55
- try:
56
- return response.json()
57
- except ValueError as exc:
58
- raise ToolCallError("Tool call returned non-JSON response") from exc
59
-
60
- def storage_search(
61
- self,
62
- query: str,
63
- top_k: int = 5,
64
- filters: Optional[Dict] = None,
65
- retries: int = 1,
66
- ) -> List[StorageHit]:
67
- """storage_search エンドポイントを呼び出してヒットを整形する。"""
68
- payload: Dict[str, object] = {"query": query, "top_k": top_k}
69
- if filters:
70
- payload["filters"] = filters
71
-
72
- attempts = 0
73
- while True:
74
- try:
75
- raw = self._post("/tools/storage_search", payload)
76
- hits = []
77
- for item in raw.get("hits", []):
78
- hits.append(
79
- StorageHit(
80
- file_id=item.get("file_id"),
81
- file_name=item.get("file_name"),
82
- chunk_id=item.get("chunk_id"),
83
- score=item.get("score"),
84
- snippet=item.get("snippet"),
85
- )
86
- )
87
- return hits
88
- except ToolCallError:
89
- attempts += 1
90
- if attempts > retries:
91
- raise
92
-
93
- def storage_get(
94
- self,
95
- *,
96
- chunk_id: Optional[str] = None,
97
- file_id: Optional[str] = None,
98
- retries: int = 1,
99
- ) -> Optional[str]:
100
- """storage_get エンドポイントからチャンクまたはファイルを取得する。"""
101
- if not chunk_id and not file_id:
102
- raise ValueError("Either chunk_id or file_id must be provided.")
103
-
104
- payload: Dict[str, object] = {}
105
- if chunk_id:
106
- payload["chunk_id"] = chunk_id
107
- if file_id:
108
- payload["file_id"] = file_id
109
-
110
- attempts = 0
111
- while True:
112
- try:
113
- raw = self._post("/tools/storage_get", payload)
114
- content = raw.get("content")
115
- if isinstance(content, str):
116
- return content
117
- return None
118
- except ToolCallError:
119
- attempts += 1
120
- if attempts > retries:
121
- raise
122
- def get_vector_store_ids_from_env() -> List[str]:
123
- """環境変数 VECTOR_STORE_ID からベクターストアIDのリストを抽出する。"""
124
- raw = os.getenv("VECTOR_STORE_ID", "").strip()
125
- if not raw:
126
- return []
127
-
128
- ids: List[str] = []
129
- for token in re.split(r"[,\s]+", raw):
130
- if not token:
131
- continue
132
- if VECTOR_STORE_ID_RE.fullmatch(token) and token not in ids:
133
- ids.append(token)
134
- return ids
135
-
136
-
137
- def generate_search_queries(question: str) -> List[str]:
138
- """自然言語の質問から最大3件の検索クエリを生成する。"""
139
- normalized = question.strip()
140
- if not normalized:
141
- return []
142
-
143
- queries: List[str] = [normalized]
144
-
145
- ascii_tokens = re.findall(r"[A-Za-z0-9]+", normalized)
146
- if ascii_tokens:
147
- english_query = " ".join(ascii_tokens)
148
- if english_query and english_query.lower() != normalized.lower():
149
- queries.append(english_query)
150
-
151
- japanese_clean = re.sub(r"[、。;;,]", " ", normalized)
152
- japanese_clean = re.sub(r"\s+", " ", japanese_clean).strip()
153
- if japanese_clean and japanese_clean not in queries:
154
- queries.append(japanese_clean)
155
 
156
- return queries[:3]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
 
 
 
 
158
 
159
- def deduplicate_hits(hits_by_query: Iterable[Tuple[str, List[StorageHit]]]) -> List[StorageHit]:
160
- """クエリごとのヒットを重複除去しながら結合する。"""
161
- seen_keys = set()
162
- merged: List[StorageHit] = []
163
- for _, hits in hits_by_query:
164
- for hit in hits:
165
- key = (hit.chunk_id, hit.file_id)
166
- if key in seen_keys:
167
- continue
168
- seen_keys.add(key)
169
- merged.append(hit)
170
- return merged
171
 
 
 
 
 
172
 
173
- def build_excerpt(text: str, limit: int = 280) -> str:
174
- """テキストを指定文字数以内に要約した抜粋を返す。"""
175
- collapsed = re.sub(r"\s+", " ", text).strip()
176
- if len(collapsed) <= limit:
177
- return collapsed
178
- return collapsed[: limit - 1] + ""
 
 
 
 
 
 
179
 
180
 
181
  def produce_answer(question: str, top_k: int) -> str:
182
- """質問に応じて検索を実行し、仕様に沿った回答テキストを生成する。"""
183
  question = (question or "").strip()
184
  if not question:
185
  return "質問が入力されていません。"
186
 
187
- api_key = os.getenv("OPENAI_API_KEY")
188
- if not api_key:
189
- return "OPENAI_API_KEY が設定されていません。環境変数を確認してください。"
190
-
191
- client = OpenAIStorageClient(api_key=api_key)
192
-
193
- vector_store_ids = get_vector_store_ids_from_env()
194
- if not vector_store_ids:
195
- return "VECTOR_STORE_ID が設定されていません。環境変数を確認してください。"
196
-
197
- queries = generate_search_queries(question)
198
- if not queries:
199
- return "該当データがありません。"
200
-
201
  try:
202
- hits_by_query = []
203
- for query in queries:
204
- filters = {"vector_store_ids": vector_store_ids}
205
- hits = client.storage_search(query=query, top_k=top_k, filters=filters)
206
- if hits:
207
- hits_by_query.append((query, hits))
208
-
209
- if not hits_by_query:
210
- return "該当データがありません。"
211
-
212
- merged_hits = deduplicate_hits(hits_by_query)
213
- evidence_lines: List[str] = []
214
-
215
- for hit in merged_hits[:3]:
216
- content: Optional[str] = None
217
- try:
218
- if hit.chunk_id:
219
- content = client.storage_get(chunk_id=hit.chunk_id)
220
- if not content and hit.file_id:
221
- content = client.storage_get(file_id=hit.file_id)
222
- except ToolCallError:
223
- # Propagate after exceeding retry budget according to spec.
224
- return "システムエラーが発生しました。時間をおいて再度お試しください。"
225
-
226
- source_text = content or hit.snippet
227
- if not source_text:
228
- continue
229
-
230
- excerpt = build_excerpt(source_text)
231
- if not excerpt:
232
- continue
233
-
234
- file_ref = hit.file_name or (hit.file_id or "unknown")
235
- chunk_ref = hit.chunk_id or "chunk"
236
- evidence_lines.append(f"- {excerpt} [{file_ref}#{chunk_ref}]")
237
-
238
- if not evidence_lines:
239
- return "該当データがありません。"
240
-
241
- return "\n".join(evidence_lines)
242
-
243
- except ToolCallError:
244
  return "システムエラーが発生しました。時間をおいて再度お試しください。"
245
 
246
 
247
  def respond(question: str, top_k: int = 5) -> str:
248
- """Gradio UI から呼び出されるエントリーポイント。"""
249
  try:
250
- return produce_answer(question, top_k=int(top_k))
251
- except Exception: # pragma: no cover - defensive fallback for UI
252
  return "システムエラーが発生しました。時間をおいて再度お試しください。"
253
 
254
 
255
  with gr.Blocks() as demo:
256
  gr.Markdown(
257
  """
258
- ## MCP Storage
 
 
259
  """
260
  )
261
 
 
1
  import os
2
+ from typing import Any, Dict, List, Tuple
 
 
3
 
4
  import gradio as gr
5
+ from openai import OpenAI
6
 
7
 
8
+ def get_openai_client() -> OpenAI:
9
+ """OpenAI API クライアントを環境変数から初期化して返す。"""
10
+ api_key = os.getenv("OPENAI_API_KEY", "").strip()
11
+ if not api_key:
12
+ raise RuntimeError("OPENAI_API_KEY が設定されていません。環境変数を確認してください。")
13
+
14
+ base_url = os.getenv("OPENAI_API_BASE", "").strip() or None
15
+ return OpenAI(api_key=api_key, base_url=base_url)
16
+
17
+
18
+ def get_vector_store_id() -> str:
19
+ """環境変数 VECTOR_STORE_ID から単一のベクターストアIDを取得する。"""
20
+ value = os.getenv("VECTOR_STORE_ID", "").strip()
21
+ if not value:
22
+ raise RuntimeError("VECTOR_STORE_ID が設定されていません。環境変数を確認してください。")
23
+ return value.split(",")[0].strip()
24
+
25
+
26
+ def extract_citations(response_dict: Dict[str, Any]) -> List[Dict[str, Any]]:
27
+ """Responses API レスポンスから file_search の引用情報を抽出する。"""
28
+ citations: List[Dict[str, Any]] = []
29
+ outputs = response_dict.get("output") or []
30
+
31
+ for output in outputs:
32
+ for content in output.get("content", []) or []:
33
+ annotations = content.get("annotations") or []
34
+ for annotation in annotations:
35
+ file_citation = annotation.get("file_citation")
36
+ if not file_citation:
37
+ continue
38
+ entry = {
39
+ "file_id": file_citation.get("file_id"),
40
+ "file_name": file_citation.get("file_name"),
41
+ "vector_store_id": file_citation.get("vector_store_id"),
42
+ "quote": file_citation.get("quote"),
43
+ }
44
+ if entry not in citations:
45
+ citations.append(entry)
46
+ return citations
47
+
48
+
49
+ def answer_with_file_search(
50
+ vector_store_id: str,
51
+ query: str,
52
+ top_k: int = 5,
53
+ model: str = "gpt-4o-mini",
54
+ ) -> Tuple[str, List[Dict[str, Any]]]:
55
+ """
56
+ Responses API + file_search ツールを用いて検索と回答を同時に行い、回答と引用を返す。
57
+
58
+ Cookbook の "Integrating search results with LLM in a single API call" を参考に構成。
59
+ """
60
+ if not query.strip():
61
+ return "", []
62
+
63
+ client = get_openai_client()
64
+ system_prompt = (
65
+ "あなたは取得したドキュメントから根拠が確認できる場合のみ回答するアシスタントです。"
66
+ "根拠が確認できない場合は「該当データがありません。」と答えてください。"
67
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ user_message = f"{query}\n\n検索結果は最大 {top_k} 件まで引用してください。"
70
+
71
+ response = client.responses.create(
72
+ model=model,
73
+ input=[
74
+ {"role": "system", "content": system_prompt},
75
+ {"role": "user", "content": user_message},
76
+ ],
77
+ tools=[
78
+ {
79
+ "type": "file_search",
80
+ "vector_store_ids": [vector_store_id],
81
+ }
82
+ ],
83
+ )
84
 
85
+ response_dict = response.to_dict_recursive() # type: ignore[attr-defined]
86
+ answer_text = response_dict.get("output_text") or ""
87
+ citations = extract_citations(response_dict)
88
+ return answer_text.strip(), citations
89
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
+ def format_answer(answer: str, citations: List[Dict[str, Any]]) -> str:
92
+ """回答文と引用リストを UI 向けのテキストに整形する。"""
93
+ if not answer:
94
+ return "該当データがありません。"
95
 
96
+ lines = [answer]
97
+ if citations:
98
+ lines.append("")
99
+ lines.append("引用:")
100
+ for citation in citations:
101
+ file_name = citation.get("file_name") or citation.get("file_id") or "unknown"
102
+ quote = citation.get("quote")
103
+ if quote:
104
+ lines.append(f"- {quote} [{file_name}]")
105
+ else:
106
+ lines.append(f"- [{file_name}]")
107
+ return "\n".join(lines)
108
 
109
 
110
  def produce_answer(question: str, top_k: int) -> str:
111
+ """Gradio UI からの質問を処理し、Responses API の結果を返す。"""
112
  question = (question or "").strip()
113
  if not question:
114
  return "質問が入力されていません。"
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  try:
117
+ vector_store_id = get_vector_store_id()
118
+ answer, citations = answer_with_file_search(vector_store_id, question, top_k=top_k)
119
+ return format_answer(answer, citations)
120
+ except RuntimeError as runtime_error:
121
+ return str(runtime_error)
122
+ except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  return "システムエラーが発生しました。時間をおいて再度お試しください。"
124
 
125
 
126
  def respond(question: str, top_k: int = 5) -> str:
127
+ """Gradio UI から呼び出されるハンドラ。"""
128
  try:
129
+ return produce_answer(question, int(top_k))
130
+ except Exception:
131
  return "システムエラーが発生しました。時間をおいて再度お試しください。"
132
 
133
 
134
  with gr.Blocks() as demo:
135
  gr.Markdown(
136
  """
137
+ ## MCP Storage レスポンダ
138
+ OpenAI Responses API の file_search ツールを利用し、指定した Vector Store から根拠付きで回答します。
139
+ OPENAI_API_KEY と VECTOR_STORE_ID を環境変数に設定してからご利用ください。
140
  """
141
  )
142