Corin1998 commited on
Commit
fe9b124
·
verified ·
1 Parent(s): fe3bc40

Update rag/ingest.py

Browse files
Files changed (1) hide show
  1. rag/ingest.py +285 -3
rag/ingest.py CHANGED
@@ -1,7 +1,192 @@
1
- # 既存の import 群に追加は不要。下記の関数をファイル末尾あたりに追記してください。
 
 
2
 
3
- def ingest_pdf_bytes(title: str, source_url: str, pdf_bytes: bytes):
4
- """ローカル/アップロードPDFをインデックスへ投入"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  text = pdf_to_text(pdf_bytes)
6
  if not text.strip():
7
  return 0
@@ -17,3 +202,100 @@ def ingest_pdf_bytes(title: str, source_url: str, pdf_bytes: bytes):
17
  if recs:
18
  add_to_index(recs)
19
  return len(recs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rag/ingest.py
2
+ """
3
+ EDINET/アップロードPDFの取り込み(RAGインデックス投入)ユーティリティ
4
 
5
+ - ingest_edinet_for_company(edinet_code, date)
6
+ 指定日の EDINET 公開一覧から該当企業の docID を探し、PDFを取得してインデックスへ投入
7
+ - ingest_pdf_bytes(title, source_url, pdf_bytes)
8
+ アップロード等のPDFバイト列をそのまま投入
9
+ - download_edinet_pdf(doc_id)
10
+ EDINET API から docID の PDF(=type=1) をダウンロード
11
+
12
+ 依存:
13
+ - irpr.deps.add_to_index …… ベクトル化+永続化
14
+ - pymupdf (fitz) …… PDF → テキスト抽出
15
+ - requests …… EDINET API 呼び出し
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import os
22
+ import re
23
+ import time
24
+ import json
25
+ import math
26
+ import datetime as dt
27
+ from typing import List, Dict, Optional
28
+
29
+ import requests
30
+ import fitz # PyMuPDF
31
+
32
+ from irpr.deps import add_to_index
33
+
34
+ # =============================================================================
35
+ # EDINET API
36
+ # =============================================================================
37
+
38
+ EDINET_API_LIST = "https://disclosure.edinet-fsa.go.jp/api/v2/documents.json"
39
+ EDINET_API_DOC = "https://disclosure.edinet-fsa.go.jp/api/v2/documents/{doc_id}"
40
+
41
+ # 代表的なドキュメント種別(必要に応じて追加)
42
+ # 120: 有価証券報告書, 130: 四半期報告書, 140: 半期報告書, 150: 臨時報告書
43
+ # 160: 参照書類, 170: 訂正有価証券報告書, 180: 訂正四半期報告書, etc.
44
+ PREFERRED_DOC_TYPES = {
45
+ "120", "130", "140", "150", "170", "180", "350", "360", "370", "380"
46
+ }
47
+
48
+
49
+ def _http_get(url: str, *, headers: Optional[dict] = None, params: Optional[dict] = None,
50
+ timeout: int = 60) -> requests.Response:
51
+ """GET with simple retry."""
52
+ last = None
53
+ for _ in range(3):
54
+ try:
55
+ r = requests.get(url, headers=headers, params=params, timeout=timeout)
56
+ if r.status_code >= 500:
57
+ time.sleep(1.2)
58
+ last = r
59
+ continue
60
+ return r
61
+ except Exception as e:
62
+ last = e
63
+ time.sleep(1.2)
64
+ if isinstance(last, requests.Response):
65
+ return last
66
+ raise RuntimeError(f"GET failed for {url}: {last!r}")
67
+
68
+
69
+ def list_edinet_docs_for_date(date: str) -> List[dict]:
70
+ """
71
+ 指定日の EDINET 公開一覧(JSON)を取得。
72
+ API仕様上、日付は必須・1日単位(時刻は不可)。
73
+ """
74
+ params = {"date": date, "type": 2} # type=2 → JSON(一覧)
75
+ r = _http_get(EDINET_API_LIST, params=params)
76
+ if r.status_code != 200:
77
+ raise RuntimeError(f"EDINET list error: {r.status_code} {r.text[:200]}")
78
+ try:
79
+ data = r.json()
80
+ except json.JSONDecodeError:
81
+ # まれにCSVが返るケースを回避(公式はJSONだが保険)
82
+ raise RuntimeError("EDINET list: JSON decode error")
83
+ return data.get("results", []) or []
84
+
85
+
86
+ def find_company_doc_ids(edinet_code: str, date: str, search_window_days: int = 2) -> List[dict]:
87
+ """
88
+ 指定日の前後 search_window_days 日をゆるく探索し、
89
+ edinetCode が一致する結果を返す(スコア順:優先種別→新しい日付)。
90
+ """
91
+ base = dt.datetime.strptime(date, "%Y-%m-%d").date()
92
+ candidates: List[dict] = []
93
+
94
+ for off in range(-search_window_days, search_window_days + 1):
95
+ d = (base + dt.timedelta(days=off)).isoformat()
96
+ try:
97
+ rows = list_edinet_docs_for_date(d)
98
+ except Exception:
99
+ continue
100
+ for row in rows:
101
+ if (row.get("edinetCode") or "").upper() == edinet_code.upper():
102
+ # スコアリング:好ましいdocTypeを優先
103
+ dtc = str(row.get("docTypeCode") or "")
104
+ score = 100 if dtc in PREFERRED_DOC_TYPES else 0
105
+ # さらに当日日付に近いほど加点(0日差=+20, 1日差=+15, …)
106
+ score += max(0, 20 - 5 * abs(off))
107
+ row["_score"] = score
108
+ row["_date"] = d
109
+ candidates.append(row)
110
+
111
+ # スコア降順、提出時間の降順でソート
112
+ candidates.sort(key=lambda x: (x.get("_score", 0), x.get("submitDateTime", "")), reverse=True)
113
+ return candidates
114
+
115
+
116
+ def download_edinet_pdf(doc_id: str) -> Optional[bytes]:
117
+ """
118
+ EDINET の docID から PDF バイト列を取得。
119
+ 環境変数 EDINET_API_KEY が必要です。
120
+ - URL 文字列が渡された場合はそのまま GET します(簡易対応)。
121
+ """
122
+ if doc_id.startswith("http://") or doc_id.startswith("https://"):
123
+ r = _http_get(doc_id)
124
+ return r.content if r.status_code == 200 else None
125
+
126
+ api_key = os.environ.get("EDINET_API_KEY")
127
+ if not api_key:
128
+ # APIキー未設定の場合は UI 側でアップロードを利用してください
129
+ raise RuntimeError("EDINET_API_KEY is not set")
130
+
131
+ url = EDINET_API_DOC.format(doc_id=doc_id)
132
+ headers = {"X-API-KEY": api_key}
133
+ # type=1 が PDF
134
+ r = _http_get(url, headers=headers, params={"type": 1})
135
+ if r.status_code != 200:
136
+ return None
137
+ return r.content
138
+
139
+
140
+ def ingest_edinet_for_company(edinet_code: str, date: str, max_docs: int = 2) -> int:
141
+ """
142
+ 会社コード+日付で EDINET doc を探し、PDFを取り込んでチャンク投入。
143
+ 返値は投入したチャンク数。
144
+ """
145
+ if not edinet_code or not date:
146
+ raise ValueError("edinet_code と date は必須です(dateはYYYY-MM-DD)")
147
+
148
+ found = find_company_doc_ids(edinet_code, date, search_window_days=2)
149
+ if not found:
150
+ return 0
151
+
152
+ consumed = 0
153
+ for row in found[:max_docs]:
154
+ doc_id = row.get("docID") or row.get("docId")
155
+ if not doc_id:
156
+ continue
157
+ title = (row.get("title") or row.get("docDescription") or "edinet").strip()
158
+ pdf = download_edinet_pdf(doc_id)
159
+ if not pdf:
160
+ continue
161
+
162
+ text = pdf_to_text(pdf)
163
+ if not text.strip():
164
+ continue
165
+
166
+ recs = []
167
+ for j, chunk in enumerate(chunk_text(text)):
168
+ recs.append({
169
+ "text": chunk,
170
+ "source_url": f"/proxy/edinet/{doc_id}",
171
+ "title": title,
172
+ "doc_id": doc_id,
173
+ "chunk_id": f"{doc_id}-{j:04d}",
174
+ })
175
+ if recs:
176
+ add_to_index(recs)
177
+ consumed += len(recs)
178
+
179
+ return consumed
180
+
181
+
182
+ # =============================================================================
183
+ # アップロードPDFの取り込み
184
+ # =============================================================================
185
+
186
+ def ingest_pdf_bytes(title: str, source_url: str, pdf_bytes: bytes) -> int:
187
+ """
188
+ ローカル/アップロードPDFをインデックスへ投入。
189
+ """
190
  text = pdf_to_text(pdf_bytes)
191
  if not text.strip():
192
  return 0
 
202
  if recs:
203
  add_to_index(recs)
204
  return len(recs)
205
+
206
+
207
+ # =============================================================================
208
+ # PDF → テキスト抽出
209
+ # =============================================================================
210
+
211
+ def pdf_to_text(pdf_bytes: bytes) -> str:
212
+ """
213
+ PyMuPDF でプレーンテキスト抽出。日本語でも高精度。
214
+ 画像だけのPDFはテキストが空になることがあります(OCRは未実装)。
215
+ """
216
+ try:
217
+ with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
218
+ texts = []
219
+ for page in doc:
220
+ # "text" は改行付きのレイアウトテキスト、"blocks"等でも可
221
+ t = page.get_text("text")
222
+ texts.append(t)
223
+ text = "\n\n".join(texts)
224
+ # 余計な全角空白の連続などを軽く正規化
225
+ text = re.sub(r"[ \t\u3000]+", " ", text)
226
+ # 連続改行の整理
227
+ text = re.sub(r"\n{3,}", "\n\n", text)
228
+ return text.strip()
229
+ except Exception:
230
+ return ""
231
+
232
+
233
+ # =============================================================================
234
+ # テキスト分割(日本語向けゆるふわチャンク)
235
+ # =============================================================================
236
+
237
+ _SENT_SPLIT_RE = re.compile(r"(.*?[\.\?\!。!?]\s*)", re.S)
238
+
239
+
240
+ def split_sentences(text: str) -> List[str]:
241
+ """
242
+ 「。!?.!?」までを1文として切り出し。末尾に句点が無い行も拾う。
243
+ """
244
+ parts = []
245
+ pos = 0
246
+ for m in _SENT_SPLIT_RE.finditer(text):
247
+ parts.append(m.group(0))
248
+ pos = m.end()
249
+ if pos < len(text):
250
+ parts.append(text[pos:])
251
+ # 行単位の段落も尊重(空行で分割したい場合はここで更に加工)
252
+ out = []
253
+ for p in parts:
254
+ p = p.strip()
255
+ if p:
256
+ out.extend([s for s in p.splitlines() if s.strip()])
257
+ return out
258
+
259
+
260
+ def chunk_text(text: str, target_chars: int = 1000, overlap_chars: int = 200) -> List[str]:
261
+ """
262
+ 文章境界をできるだけ保ちながら、概ね target_chars で分割。
263
+ チャンク間に overlap_chars の重なりをつけ、RAGの一致率を上げる。
264
+ """
265
+ if not text:
266
+ return []
267
+
268
+ sents = split_sentences(text)
269
+ chunks: List[str] = []
270
+ buf: List[str] = []
271
+ size = 0
272
+
273
+ def flush():
274
+ if not buf:
275
+ return
276
+ chunk = "".join(buf).strip()
277
+ if chunk:
278
+ chunks.append(chunk)
279
+
280
+ for s in sents:
281
+ if size + len(s) <= target_chars or not buf:
282
+ buf.append(s)
283
+ size += len(s)
284
+ continue
285
+ # いったん確定
286
+ flush()
287
+ # オーバーラップ確保
288
+ tail = []
289
+ remain = overlap_chars
290
+ # 後ろから文を足していく
291
+ for t in reversed(buf):
292
+ if remain <= 0:
293
+ break
294
+ tail.append(t)
295
+ remain -= len(t)
296
+ tail.reverse()
297
+ buf = tail + [s]
298
+ size = sum(len(x) for x in buf)
299
+
300
+ flush()
301
+ return chunks