app.py 86 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935
  1. import os
  2. import pymysql
  3. import requests
  4. import json
  5. import re
  6. import threading
  7. import urllib3
  8. import fitz # PyMuPDF
  9. from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, Response, stream_with_context
  10. from werkzeug.utils import secure_filename
  11. from oss_utils import upload_to_oss
  12. from ocr_utils import extract_page_number
  13. import time
  14. from datetime import datetime
  15. # Suppress InsecureRequestWarning
  16. urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
  17. app = Flask(__name__, static_folder='static', static_url_path='/manager/static')
  18. app.secret_key = 'genealogy_secret_key'
  19. app.config['UPLOAD_FOLDER'] = 'uploads'
  20. os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
  21. # 数据库配置
  22. DB_CONFIG = {
  23. "host": "rm-f8ze60yirdj8786u2wo.mysql.rds.aliyuncs.com",
  24. "port": 3306,
  25. "user": "csqz",
  26. "password": "csqz@2026",
  27. "db": "csqz-client",
  28. "charset": "utf8mb4",
  29. "cursorclass": pymysql.cursors.DictCursor
  30. }
  31. from PIL import Image
  32. def compress_image_if_needed(file_path, max_dim=2000):
  33. """Compress, resize and normalize image to JPEG for AI processing."""
  34. try:
  35. # We always want to normalize to JPEG so AI doesn't complain about format
  36. with Image.open(file_path) as img:
  37. # Convert RGBA/P or any other mode to RGB for JPEG saving
  38. if img.mode != 'RGB':
  39. img = img.convert('RGB')
  40. width, height = img.size
  41. if max(width, height) > max_dim:
  42. ratio = max_dim / max(width, height)
  43. new_size = (int(width * ratio), int(height * ratio))
  44. img = img.resize(new_size, Image.Resampling.LANCZOS)
  45. # Always save as JPEG to normalize the format
  46. new_path = os.path.splitext(file_path)[0] + '_normalized.jpg'
  47. img.save(new_path, 'JPEG', quality=85)
  48. return new_path
  49. except Exception as e:
  50. print(f"Warning: Image compression/normalization failed for {file_path}: {e}")
  51. return file_path
  52. def get_db_connection():
  53. return pymysql.connect(**DB_CONFIG)
  54. def format_timestamp(ts):
  55. if not ts: return '未知'
  56. try:
  57. # 兼容秒和毫秒
  58. if ts > 10000000000: # 超过2286年的秒数,通常认为是毫秒
  59. ts = ts / 1000
  60. return time.strftime('%Y-%m-%d', time.localtime(ts))
  61. except:
  62. return '未知'
  63. def manual_simplify(text):
  64. """
  65. Simple fallback for common Traditional to Simplified conversion
  66. if AI fails to convert specific characters.
  67. """
  68. if not text: return text
  69. mapping = {
  70. '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
  71. '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
  72. '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
  73. '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
  74. '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
  75. '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
  76. '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
  77. '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
  78. '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
  79. '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
  80. '銳': '锐', '優': '优', '楊': '杨', '吳': '吴', '銀': '银'
  81. }
  82. result = ""
  83. for char in text:
  84. result += mapping.get(char, char)
  85. return result
  86. def _build_reverse_simplify_map():
  87. """
  88. Build a reverse map from simplified char -> list of traditional chars
  89. based on the fallback manual_simplify mapping.
  90. """
  91. mapping = {
  92. '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
  93. '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
  94. '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
  95. '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
  96. '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
  97. '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
  98. '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
  99. '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
  100. '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
  101. '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
  102. '銳': '锐', '優': '优', '楊': '杨', '吳': '吴', '銀': '银'
  103. }
  104. rev = {}
  105. for trad, simp in mapping.items():
  106. rev.setdefault(simp, [])
  107. if trad not in rev[simp]:
  108. rev[simp].append(trad)
  109. return rev
  110. _REVERSE_SIMPLIFY_MAP = _build_reverse_simplify_map()
  111. def expand_name_search_variants(keyword, max_variants=60):
  112. """
  113. Expand keyword into a small set of variants so Simplified/Traditional
  114. searches can match both `name` and `simplified_name`.
  115. - Always includes original keyword
  116. - Includes fallback-trad->simp conversion
  117. - Includes best-effort simp->trad expansions based on reverse map
  118. """
  119. if not keyword:
  120. return []
  121. kw = str(keyword).strip()
  122. if not kw:
  123. return []
  124. variants = set([kw])
  125. variants.add(manual_simplify(kw))
  126. # Build possible traditional variants when the input is simplified.
  127. # For each char, if we have traditional candidates, branch; otherwise keep itself.
  128. choices = []
  129. for ch in kw:
  130. cand = _REVERSE_SIMPLIFY_MAP.get(ch)
  131. if cand:
  132. # include itself too (covers already-traditional or neutral chars)
  133. choices.append([ch] + cand)
  134. else:
  135. choices.append([ch])
  136. # Cartesian product with early stop.
  137. results = ['']
  138. for opts in choices:
  139. new_results = []
  140. for prefix in results:
  141. for opt in opts:
  142. new_results.append(prefix + opt)
  143. if len(new_results) >= max_variants:
  144. break
  145. if len(new_results) >= max_variants:
  146. break
  147. results = new_results
  148. if len(results) >= max_variants:
  149. break
  150. for r in results:
  151. if r:
  152. variants.add(r)
  153. variants.add(manual_simplify(r))
  154. # Keep deterministic order for stable SQL params
  155. ordered = []
  156. for v in variants:
  157. v2 = (v or '').strip()
  158. if v2 and v2 not in ordered:
  159. ordered.append(v2)
  160. if len(ordered) >= max_variants:
  161. break
  162. return ordered
  163. def clean_name(name):
  164. """
  165. Clean name according to Liu family genealogy rules:
  166. 1. If name is '学公' or '留学公', keep 'Gong' (exception).
  167. 2. Otherwise, if name ends with '公', remove '公'.
  168. 3. If name does not start with '留', prepend '留'.
  169. """
  170. if not name: return name
  171. name = name.strip()
  172. # Pre-process: Ensure Simplified Chinese for specific chars
  173. name = manual_simplify(name)
  174. # 1. Check exceptions (names that SHOULD keep 'Gong')
  175. exceptions = ['学公', '留学公']
  176. if name in exceptions:
  177. if not name.startswith('留'):
  178. name = '留' + name
  179. return name
  180. # 2. General Rule: Remove 'Gong' suffix
  181. if name.endswith('公'):
  182. name = name[:-1]
  183. # 3. Ensure 'Liu' surname
  184. if not name.startswith('留'):
  185. name = '留' + name
  186. return name
  187. def is_female_value(sex_value):
  188. """Return True when sex value represents female."""
  189. if sex_value is None:
  190. return False
  191. s = str(sex_value).strip().lower()
  192. return s in ('女', '2', 'female', 'f')
  193. def normalize_lookup_name(name):
  194. """Normalize names for loose matching in AI parsed content."""
  195. if not name:
  196. return ''
  197. return manual_simplify(str(name)).strip()
  198. def should_skip_liu_prefix_for_person(person, spouse_name_set):
  199. """
  200. Female spouse records should not auto-prepend '留' in simplified_name.
  201. We treat a person as female spouse if:
  202. 1) sex is female, and
  203. 2) has spouse_name field OR appears in another person's spouse_name list.
  204. """
  205. if not isinstance(person, dict):
  206. return False
  207. if not is_female_value(person.get('sex')):
  208. return False
  209. own_names = set()
  210. own_names.add(normalize_lookup_name(person.get('name')))
  211. own_names.add(normalize_lookup_name(person.get('original_name')))
  212. own_names.discard('')
  213. has_spouse_name = bool(normalize_lookup_name(person.get('spouse_name')))
  214. referenced_by_other = any(n in spouse_name_set for n in own_names)
  215. return has_spouse_name or referenced_by_other
  216. def get_normalized_base64_image(image_url):
  217. """Download image, normalize to JPEG, and return base64 data URI for AI payload."""
  218. import io
  219. import base64
  220. import requests
  221. from PIL import Image
  222. try:
  223. response = requests.get(image_url, timeout=30)
  224. response.raise_for_status()
  225. with Image.open(io.BytesIO(response.content)) as img:
  226. # Convert to RGB to ensure JPEG compatibility
  227. if img.mode != 'RGB':
  228. img = img.convert('RGB')
  229. # Resize if too large
  230. max_dim = 2000
  231. if max(img.width, img.height) > max_dim:
  232. ratio = max_dim / max(img.width, img.height)
  233. new_size = (int(img.width * ratio), int(img.height * ratio))
  234. img = img.resize(new_size, Image.Resampling.LANCZOS)
  235. # Save as JPEG in memory
  236. buffer = io.BytesIO()
  237. img.save(buffer, format='JPEG', quality=85)
  238. b64_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
  239. return f"data:image/jpeg;base64,{b64_str}"
  240. except Exception as e:
  241. print(f"Error normalizing image from {image_url}: {e}")
  242. return image_url # Fallback to original URL if processing fails
  243. def process_ai_task(record_id, image_url):
  244. """Background task to process image with AI and store result."""
  245. print(f"[AI Task] Starting task for record {record_id}...")
  246. conn = get_db_connection()
  247. try:
  248. with conn.cursor() as cursor:
  249. cursor.execute("UPDATE genealogy_records SET ai_status = 1 WHERE id = %s", (record_id,))
  250. conn.commit()
  251. print(f"[AI Task] Status updated to 'Processing' for record {record_id}")
  252. api_key = "a1800657-9212-4afe-9b7c-b49f015c54d3"
  253. api_url = "https://ark.cn-beijing.volces.com/api/v3/responses"
  254. prompt = """
  255. 请分析这张家谱图片,提取其中关于人物的信息。
  256. 请务必将繁体字转换为简体字(original_name 字段除外)。
  257. 特别注意:'name' 字段必须是纯简体中文,不能包含繁体字(例如:'學'应转换为'学','劉'应转换为'刘','萬'应转换为'万')。
  258. 请提取以下字段(如果存在):
  259. - original_name: 原始姓名(严格保持图片上的繁体字,不做任何修改或转换)
  260. - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
  261. - sex: 性别(男/女)
  262. - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
  263. - death_date: 逝世日期(如文本中出现“殁”、“葬”、“卒”等字眼及其对应的时间,请提取)
  264. - father_name: 父亲姓名
  265. - spouse_name: 配偶姓名
  266. - generation: 第几世/代数
  267. - name_word: 字辈(例如名字为“学勤公”,“学”为字辈;提取名字中的字辈信息)
  268. - education: 学历/功名
  269. - title: 官职/称号
  270. 请严格以JSON列表格式返回,不要包含Markdown代码块标记(如 ```json ... ```),直接返回JSON数组。
  271. 如果包含多个人物,请都提取出来。
  272. Do not output any reasoning or explanation, just the JSON.
  273. """
  274. ai_payload_url = get_normalized_base64_image(image_url)
  275. payload = {
  276. "model": "doubao-seed-1-8-251228",
  277. "stream": True, # Streaming for robust handling
  278. "input": [
  279. {
  280. "role": "user",
  281. "content": [
  282. {"type": "input_image", "image_url": ai_payload_url},
  283. {"type": "input_text", "text": prompt}
  284. ]
  285. }
  286. ]
  287. }
  288. headers = {
  289. "Authorization": f"Bearer {api_key}",
  290. "Content-Type": "application/json"
  291. }
  292. max_retries = 3
  293. last_exception = None
  294. for attempt in range(max_retries):
  295. try:
  296. print(f"[AI Task] Attempt {attempt+1}/{max_retries} connecting to API for record {record_id}...")
  297. response = requests.post(
  298. api_url,
  299. json=payload,
  300. headers=headers,
  301. timeout=1200,
  302. stream=True,
  303. verify=False,
  304. proxies={"http": None, "https": None}
  305. )
  306. if response.status_code == 200:
  307. print(f"[AI Task] Connection established for record {record_id}, receiving stream...")
  308. full_content = ""
  309. for line in response.iter_lines():
  310. if not line: continue
  311. line_str = line.decode('utf-8')
  312. # Debug: Print full line to understand event flow
  313. print(f"[AI Task Debug] Raw Line: {line_str[:500]}") # Truncate very long lines
  314. if line_str.startswith('data: '):
  315. json_str = line_str[6:]
  316. if json_str.strip() == '[DONE]':
  317. print("[AI Task Debug] Received [DONE]")
  318. break
  319. try:
  320. chunk = json.loads(json_str)
  321. chunk_type = chunk.get('type')
  322. # Standard OpenAI format (choices)
  323. if 'choices' in chunk and len(chunk['choices']) > 0:
  324. delta = chunk['choices'][0].get('delta', {})
  325. if 'content' in delta:
  326. full_content += delta['content']
  327. # Doubao/Volcengine specific formats (delta)
  328. elif chunk_type == 'response.text.delta':
  329. full_content += chunk.get('delta', '')
  330. # Check response.completed if empty
  331. elif chunk_type == 'response.completed' and not full_content:
  332. output = chunk.get('response', {}).get('output', [])
  333. for item in output:
  334. # Also extract from reasoning if it contains JSON-like text
  335. if item.get('type') == 'reasoning':
  336. summary = item.get('summary', [])
  337. for sum_item in summary:
  338. if sum_item.get('type') == 'summary_text':
  339. full_content += sum_item.get('text', '')
  340. elif item.get('type') == 'message':
  341. content = item.get('content')
  342. if isinstance(content, str):
  343. full_content += content
  344. elif isinstance(content, list):
  345. for part in content:
  346. if isinstance(part, dict) and part.get('type') == 'text':
  347. full_content += part.get('text', '')
  348. # Fallback: output_item.added
  349. elif chunk_type == 'response.output_item.added':
  350. item = chunk.get('item', {})
  351. if item.get('role') == 'assistant':
  352. content_field = item.get('content', [])
  353. if isinstance(content_field, str):
  354. full_content += content_field
  355. elif isinstance(content_field, list):
  356. for part in content_field:
  357. if isinstance(part, dict) and part.get('type') == 'text':
  358. full_content += part.get('text', '')
  359. except Exception as e:
  360. print(f"[AI Task] Chunk parse error: {e}")
  361. else:
  362. # Fallback for non-SSE
  363. try:
  364. chunk = json.loads(line_str)
  365. if 'choices' in chunk and len(chunk['choices']) > 0:
  366. content = chunk['choices'][0]['message']['content']
  367. full_content += content
  368. except:
  369. pass
  370. print(f"[AI Task] Stream finished. Content length: {len(full_content)}")
  371. if len(full_content) == 0:
  372. print(f"[AI Task] WARNING: No content received from AI stream.")
  373. # Continue to JSON parse to fail gracefully
  374. # Clean JSON
  375. try:
  376. # 1. Try finding [...] array
  377. start = full_content.find('[')
  378. end = full_content.rfind(']')
  379. # 2. If not found, try finding {...} object and wrap it
  380. is_single_object = False
  381. if start == -1 or end == -1 or end <= start:
  382. start = full_content.find('{')
  383. end = full_content.rfind('}')
  384. is_single_object = True
  385. if start != -1 and end != -1 and end > start:
  386. content_clean = full_content[start:end+1]
  387. else:
  388. # Fallback to regex or raw
  389. content_clean = re.sub(r'^```json\s*', '', full_content)
  390. content_clean = re.sub(r'```$', '', content_clean)
  391. parsed = json.loads(content_clean)
  392. # Normalize single object to list
  393. if is_single_object and isinstance(parsed, dict):
  394. parsed = [parsed]
  395. content_clean = json.dumps(parsed, ensure_ascii=False)
  396. elif isinstance(parsed, dict) and not isinstance(parsed, list):
  397. # Just in case json.loads parsed a dict even if we looked for []
  398. parsed = [parsed]
  399. content_clean = json.dumps(parsed, ensure_ascii=False)
  400. # Build spouse name lookup for "female spouse" detection
  401. spouse_name_set = set()
  402. if isinstance(parsed, list):
  403. for person in parsed:
  404. n = normalize_lookup_name(person.get('spouse_name'))
  405. if n:
  406. spouse_name_set.add(n)
  407. # Clean names in parsed content
  408. if isinstance(parsed, list):
  409. for person in parsed:
  410. # Process Name: 'name' is Simplified from AI, 'original_name' is Traditional/Raw from AI
  411. simplified_name = person.get('name', '') or person.get('original_name', '')
  412. original_name = person.get('original_name', '')
  413. # Female spouse: only simplify Chinese, do NOT prepend '留'
  414. if should_skip_liu_prefix_for_person(person, spouse_name_set):
  415. cleaned_simplified = manual_simplify(simplified_name)
  416. else:
  417. # Same-clan default: prepend '留' and handle trailing '公'
  418. cleaned_simplified = clean_name(simplified_name)
  419. person['simplified_name'] = cleaned_simplified
  420. # Store raw name in 'name' field (as requested)
  421. if original_name:
  422. person['name'] = original_name
  423. else:
  424. # Fallback: if no original_name returned, use the uncleaned name as 'name'
  425. # or keep existing logic. But user wants raw in 'name'.
  426. # If AI didn't return original_name, 'name' is likely simplified.
  427. pass # Keep 'name' as is (which is Simplified) if original_name missing
  428. # Father name:同族,需要按“留”姓规则清洗
  429. if 'father_name' in person and person['father_name']:
  430. person['father_name'] = clean_name(person['father_name'])
  431. # Spouse name:只做繁转简,不拼接“留”姓,也不去“公”
  432. if 'spouse_name' in person and person['spouse_name']:
  433. person['spouse_name'] = manual_simplify(person['spouse_name'])
  434. # Re-serialize
  435. content_clean = json.dumps(parsed, ensure_ascii=False)
  436. with conn.cursor() as cursor:
  437. cursor.execute("UPDATE genealogy_records SET ai_status = 2, ai_content = %s WHERE id = %s", (content_clean, record_id))
  438. conn.commit()
  439. print(f"[AI Task] SUCCESS: Record {record_id} processed and saved.")
  440. return # Success
  441. except json.JSONDecodeError as err:
  442. raise Exception(f"JSON Parse Error: {str(err)}. Raw: {full_content}")
  443. else:
  444. raise Exception(f"API Error {response.status_code}: {response.text}")
  445. except Exception as e:
  446. print(f"[AI Task] Attempt {attempt+1} failed for record {record_id}: {e}")
  447. last_exception = e
  448. if attempt < max_retries - 1:
  449. wait_time = 2 * (attempt + 1)
  450. print(f"[AI Task] Waiting {wait_time}s before retry...")
  451. time.sleep(wait_time)
  452. raise last_exception or Exception("Unknown error")
  453. except Exception as e:
  454. print(f"[AI Task] FINAL FAILURE for record {record_id}: {e}")
  455. try:
  456. with conn.cursor() as cursor:
  457. cursor.execute("UPDATE genealogy_records SET ai_status = 3, ai_content = %s WHERE id = %s", (f"Max Retries Exceeded. Error: {str(e)}", record_id))
  458. conn.commit()
  459. except:
  460. pass
  461. finally:
  462. conn.close()
  463. print(f"[AI Task] Task finished for record {record_id}")
  464. def ensure_pdf_table():
  465. conn = get_db_connection()
  466. try:
  467. with conn.cursor() as cursor:
  468. cursor.execute("""
  469. CREATE TABLE IF NOT EXISTS genealogy_pdfs (
  470. id INT AUTO_INCREMENT PRIMARY KEY,
  471. file_name VARCHAR(255) NOT NULL,
  472. oss_url TEXT NOT NULL,
  473. description VARCHAR(500) DEFAULT '',
  474. upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  475. uploader VARCHAR(100) DEFAULT ''
  476. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  477. """)
  478. conn.commit()
  479. finally:
  480. conn.close()
  481. @app.route('/manager/pdf_management')
  482. def pdf_management():
  483. if 'user_id' not in session:
  484. return redirect(url_for('login'))
  485. ensure_pdf_table()
  486. view_id = request.args.get('view', type=int)
  487. selected_pdf = None
  488. conn = get_db_connection()
  489. try:
  490. with conn.cursor() as cursor:
  491. cursor.execute("SELECT * FROM genealogy_pdfs ORDER BY upload_time DESC")
  492. pdfs = cursor.fetchall()
  493. if view_id:
  494. cursor.execute("SELECT * FROM genealogy_pdfs WHERE id = %s", (view_id,))
  495. selected_pdf = cursor.fetchone()
  496. elif pdfs:
  497. selected_pdf = pdfs[0]
  498. finally:
  499. conn.close()
  500. return render_template('pdf_management.html', pdfs=pdfs, selected_pdf=selected_pdf)
  501. @app.route('/manager/delete_pdf/<int:pdf_id>', methods=['POST'])
  502. def delete_pdf(pdf_id):
  503. if 'user_id' not in session:
  504. return jsonify({"success": False, "message": "Unauthorized"}), 401
  505. conn = get_db_connection()
  506. try:
  507. with conn.cursor() as cursor:
  508. cursor.execute("DELETE FROM genealogy_pdfs WHERE id = %s", (pdf_id,))
  509. conn.commit()
  510. flash('PDF文件记录已删除')
  511. except Exception as e:
  512. flash(f'删除失败: {e}')
  513. finally:
  514. conn.close()
  515. return redirect(url_for('pdf_management'))
  516. @app.route('/manager/')
  517. def index():
  518. if 'user_id' not in session:
  519. return redirect(url_for('login'))
  520. page = request.args.get('page', 1, type=int)
  521. version = request.args.get('version', '').strip()
  522. source = request.args.get('source', '').strip()
  523. person = request.args.get('person', '').strip()
  524. file_type = request.args.get('file_type', '').strip()
  525. per_page = 10
  526. offset = (page - 1) * per_page
  527. conn = get_db_connection()
  528. try:
  529. with conn.cursor() as cursor:
  530. query_conditions = []
  531. params = []
  532. if version:
  533. query_conditions.append("genealogy_version LIKE %s")
  534. params.append(f"%{version}%")
  535. if source:
  536. query_conditions.append("genealogy_source LIKE %s")
  537. params.append(f"%{source}%")
  538. if person:
  539. query_conditions.append("upload_person LIKE %s")
  540. params.append(f"%{person}%")
  541. if file_type:
  542. query_conditions.append("file_type = %s")
  543. params.append(file_type)
  544. where_clause = ""
  545. if query_conditions:
  546. where_clause = "WHERE " + " AND ".join(query_conditions)
  547. count_sql = f"SELECT COUNT(*) as count FROM genealogy_records {where_clause}"
  548. cursor.execute(count_sql, params)
  549. total = cursor.fetchone()['count']
  550. sql = f"SELECT * FROM genealogy_records {where_clause} ORDER BY page_number ASC LIMIT %s OFFSET %s"
  551. cursor.execute(sql, params + [per_page, offset])
  552. records = cursor.fetchall()
  553. total_pages = (total + per_page - 1) // per_page
  554. finally:
  555. conn.close()
  556. return render_template('index.html', records=records, page=page, total_pages=total_pages, version=version, source=source, person=person, file_type=file_type, total=total)
  557. @app.route('/manager/members')
  558. def members():
  559. if 'user_id' not in session:
  560. return redirect(url_for('login'))
  561. search_name = request.args.get('name', '').strip()
  562. page = request.args.get('page', 1, type=int)
  563. per_page = 10
  564. offset = (page - 1) * per_page
  565. conn = get_db_connection()
  566. try:
  567. with conn.cursor() as cursor:
  568. # 1. Get total count
  569. if search_name:
  570. variants = expand_name_search_variants(search_name)
  571. where_parts = []
  572. params = []
  573. for v in variants:
  574. where_parts.append("(name LIKE %s OR simplified_name LIKE %s)")
  575. like = f"%{v}%"
  576. params.extend([like, like])
  577. where_clause = " OR ".join(where_parts) if where_parts else "name LIKE %s"
  578. if not where_parts:
  579. params = [f"%{search_name}%"]
  580. cursor.execute(f"SELECT COUNT(*) as count FROM family_member_info WHERE {where_clause}", tuple(params))
  581. else:
  582. cursor.execute("SELECT COUNT(*) as count FROM family_member_info")
  583. result = cursor.fetchone()
  584. total = result['count'] if result else 0
  585. total_pages = (total + per_page - 1) // per_page
  586. # 2. Get paginated results, ordered by modified_time DESC (or create_time if modified is null/same)
  587. # Using COALESCE to ensure sort works even if modified_time is NULL
  588. order_clause = "ORDER BY COALESCE(modified_time, create_time) DESC"
  589. if search_name:
  590. variants = expand_name_search_variants(search_name)
  591. where_parts = []
  592. params = []
  593. for v in variants:
  594. where_parts.append("(name LIKE %s OR simplified_name LIKE %s)")
  595. like = f"%{v}%"
  596. params.extend([like, like])
  597. where_clause = " OR ".join(where_parts) if where_parts else "(name LIKE %s OR simplified_name LIKE %s)"
  598. if not where_parts:
  599. like = f"%{search_name}%"
  600. params = [like, like]
  601. sql = f"SELECT * FROM family_member_info WHERE {where_clause} {order_clause} LIMIT %s OFFSET %s"
  602. cursor.execute(sql, tuple(params + [per_page, offset]))
  603. else:
  604. sql = f"SELECT * FROM family_member_info {order_clause} LIMIT %s OFFSET %s"
  605. cursor.execute(sql, (per_page, offset))
  606. members = cursor.fetchall()
  607. # 格式化日期
  608. for m in members:
  609. m['birthday_str'] = format_timestamp(m.get('birthday'))
  610. # 格式化创建时间 (针对 TIMESTAMP 字段)
  611. if m.get('create_time'):
  612. m['create_time_str'] = m['create_time'].strftime('%Y-%m-%d')
  613. if m.get('modified_time'):
  614. m['modified_time_str'] = m['modified_time'].strftime('%Y-%m-%d %H:%M')
  615. finally:
  616. conn.close()
  617. return render_template('members.html', members=members, search_name=search_name, page=page, total_pages=total_pages, total=total)
  618. @app.route('/manager/tree')
  619. def tree():
  620. if 'user_id' not in session:
  621. return redirect(url_for('login'))
  622. return render_template('tree.html')
  623. @app.route('/manager/tree_classic')
  624. def tree_classic():
  625. if 'user_id' not in session:
  626. return redirect(url_for('login'))
  627. return render_template('tree_classic.html')
  628. @app.route('/manager/api/tree_data')
  629. def tree_data():
  630. if 'user_id' not in session:
  631. return jsonify({"error": "Unauthorized"}), 401
  632. conn = get_db_connection()
  633. try:
  634. with conn.cursor() as cursor:
  635. # 获取所有成员
  636. cursor.execute("SELECT id, name, simplified_name, sex, family_rank, name_word_generation FROM family_member_info")
  637. members = cursor.fetchall()
  638. # 获取所有关系 (1:父子 2:母子 10:夫妻 11:兄弟 12:姐妹)
  639. cursor.execute("SELECT parent_mid, child_mid, relation_type FROM family_relation_info")
  640. relations = cursor.fetchall()
  641. return jsonify({"members": members, "relations": relations})
  642. finally:
  643. conn.close()
  644. @app.route('/manager/api/save_relation', methods=['POST'])
  645. def save_relation():
  646. if 'user_id' not in session:
  647. return jsonify({"success": False, "message": "Unauthorized"}), 401
  648. data = request.json
  649. source_mid = data.get('source_mid') # The member being dragged
  650. target_mid = data.get('target_mid') # The member being dropped onto
  651. rel_type = int(data.get('relation_type'))
  652. sub_rel_type = int(data.get('sub_relation_type', 0))
  653. if not source_mid or not target_mid or not rel_type:
  654. return jsonify({"success": False, "message": "参数不完整"}), 400
  655. conn = get_db_connection()
  656. try:
  657. with conn.cursor() as cursor:
  658. # 简单处理:如果是父子/母子关系
  659. # target_mid 是父辈,source_mid 是子辈
  660. parent_mid = target_mid
  661. child_mid = source_mid
  662. gen_diff = 1
  663. if rel_type == 10: # 夫妻
  664. # 夫妻关系中,我们通常把关联人设为 parent_mid
  665. parent_mid = target_mid
  666. child_mid = source_mid
  667. gen_diff = 0
  668. elif rel_type in [11, 12]: # 兄弟姐妹
  669. # 这里逻辑上比较复杂,通常兄弟姐妹有共同父母。
  670. # 简化处理:暂时存为同级关系 (gen_diff=0)
  671. parent_mid = target_mid
  672. child_mid = source_mid
  673. gen_diff = 0
  674. # 删除旧关系
  675. cursor.execute("DELETE FROM family_relation_info WHERE source_mid = %s", (source_mid,))
  676. # 插入新关系
  677. sql = """
  678. INSERT INTO family_relation_info
  679. (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff)
  680. VALUES (%s, %s, %s, %s, %s, %s)
  681. """
  682. cursor.execute(sql, (parent_mid, child_mid, rel_type, sub_rel_type, source_mid, gen_diff))
  683. conn.commit()
  684. return jsonify({"success": True, "message": "关系已保存"})
  685. except Exception as e:
  686. return jsonify({"success": False, "message": str(e)}), 500
  687. finally:
  688. conn.close()
  689. @app.route('/manager/api/check_relations', methods=['POST'])
  690. def check_relations():
  691. if 'user_id' not in session:
  692. return jsonify({"success": False, "message": "Unauthorized"}), 401
  693. data = request.json
  694. people = data.get('people', [])
  695. if not people:
  696. return jsonify({"success": False, "matches": {}})
  697. conn = get_db_connection()
  698. matches = {}
  699. try:
  700. with conn.cursor() as cursor:
  701. # Collect all father names and spouse names to query
  702. names_to_check = set()
  703. for p in people:
  704. if p.get('father_name'): names_to_check.add(p['father_name'])
  705. if p.get('spouse_name'): names_to_check.add(p['spouse_name'])
  706. if not names_to_check:
  707. return jsonify({"success": True, "matches": {}})
  708. # Query DB
  709. format_strings = ','.join(['%s'] * len(names_to_check))
  710. if names_to_check:
  711. sql = "SELECT id, name, simplified_name, sex, birthday FROM family_member_info WHERE name IN (%s) OR simplified_name IN (%s)" % (format_strings, format_strings)
  712. cursor.execute(sql, tuple(names_to_check) * 2)
  713. results = cursor.fetchall()
  714. else:
  715. results = []
  716. # Organize by name
  717. db_map = {} # name -> [list of members]
  718. for r in results:
  719. # Add under 'name' (Traditional/Old Simplified)
  720. if r['name'] not in db_map: db_map[r['name']] = []
  721. db_map[r['name']].append(r)
  722. # Add under 'simplified_name' if exists
  723. if r.get('simplified_name'):
  724. sname = r['simplified_name']
  725. if sname not in db_map: db_map[sname] = []
  726. # Avoid duplicates if simplified_name is same as name?
  727. # The list might contain same object reference, which is fine.
  728. if sname != r['name']:
  729. db_map[sname].append(r)
  730. # Build matches for each input person
  731. for index, p in enumerate(people):
  732. p_match = {}
  733. # Check Father
  734. fname = p.get('father_name')
  735. if fname and fname in db_map:
  736. candidates = db_map[fname]
  737. # Filter: Father should be Male usually, and older than child (if birthday available)
  738. valid_fathers = [c for c in candidates if c['sex'] == 1]
  739. if valid_fathers:
  740. p_match['father'] = valid_fathers # Return all candidates
  741. # Check Spouse
  742. sname = p.get('spouse_name')
  743. if sname and sname in db_map:
  744. candidates = db_map[sname]
  745. # Filter: Spouse usually opposite sex
  746. target_sex = 1 if p.get('sex') == '女' else 2
  747. valid_spouses = [c for c in candidates if c['sex'] == target_sex]
  748. if valid_spouses:
  749. p_match['spouse'] = valid_spouses
  750. if p_match:
  751. matches[index] = p_match
  752. return jsonify({"success": True, "matches": matches})
  753. finally:
  754. conn.close()
  755. @app.route('/manager/add_member', methods=['GET', 'POST'])
  756. def add_member():
  757. if 'user_id' not in session:
  758. return redirect(url_for('login'))
  759. conn = get_db_connection()
  760. try:
  761. # Check for source_record_id (from GET or POST)
  762. source_record_id = request.args.get('record_id') or request.form.get('source_record_id')
  763. prefilled_content = None
  764. source_oss_url = None
  765. if source_record_id:
  766. with conn.cursor() as cursor:
  767. cursor.execute("SELECT oss_url, ai_content, ai_status FROM genealogy_records WHERE id = %s", (source_record_id,))
  768. rec = cursor.fetchone()
  769. if rec:
  770. source_oss_url = rec['oss_url']
  771. # Check ai_status (2 = success)
  772. if rec['ai_status'] == 2 and rec['ai_content']:
  773. prefilled_content = rec['ai_content']
  774. if request.method == 'POST':
  775. # 处理生日转换为 Unix 时间戳
  776. birthday_str = request.form.get('birthday')
  777. birthday_ts = 0
  778. if birthday_str:
  779. try:
  780. birthday_ts = int(datetime.strptime(birthday_str, '%Y-%m-%d').timestamp())
  781. except ValueError:
  782. birthday_ts = 0
  783. # 关系数据
  784. related_mid = request.form.get('related_mid')
  785. relation_type = request.form.get('relation_type')
  786. sub_relation_type = request.form.get('sub_relation_type', 0)
  787. # 年龄校验逻辑
  788. if related_mid and relation_type in ['1', '2']: # 1:父子 2:母子
  789. with conn.cursor() as cursor:
  790. cursor.execute("SELECT name, birthday FROM family_member_info WHERE id = %s", (related_mid,))
  791. parent = cursor.fetchone()
  792. if parent and parent['birthday'] > 0 and birthday_ts > 0:
  793. if birthday_ts < parent['birthday']:
  794. error_msg = f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。"
  795. flash(error_msg)
  796. # Re-fetch data for rendering
  797. cursor.execute("SELECT id, name FROM family_member_info ORDER BY name")
  798. all_members = cursor.fetchall()
  799. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  800. images = cursor.fetchall()
  801. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  802. return jsonify({
  803. "success": False,
  804. "message": error_msg
  805. }), 400
  806. return render_template('add_member.html', all_members=all_members, images=images,
  807. prefilled_content=prefilled_content, source_oss_url=source_oss_url, source_record_id=source_record_id)
  808. # 获取表单数据
  809. data = {
  810. 'name': request.form['name'],
  811. 'simplified_name': request.form.get('simplified_name'),
  812. 'former_name': request.form.get('former_name'),
  813. 'childhood_name': request.form.get('childhood_name'),
  814. 'name_word': request.form.get('name_word'),
  815. 'name_word_generation': ';'.join([g.strip() for g in request.form.getlist('lineage_generations[]') if g.strip()]),
  816. 'name_title': request.form.get('name_title'),
  817. 'sex': request.form['sex'],
  818. 'birthday': birthday_ts,
  819. 'is_pass_away': request.form.get('is_pass_away', 0),
  820. 'marital_status': request.form.get('marital_status', 0),
  821. 'birth_place': request.form.get('birth_place'),
  822. 'branch_family_hall': request.form.get('branch_family_hall'),
  823. 'cluster_place': request.form.get('cluster_place'),
  824. 'nation': request.form.get('nation'),
  825. 'residential_address': request.form.get('residential_address'),
  826. 'phone': request.form.get('phone'),
  827. 'mail': request.form.get('mail'),
  828. 'wechat_account': request.form.get('wechat_account'),
  829. 'id_number': request.form.get('id_number'),
  830. 'occupation': request.form.get('occupation'),
  831. 'educational': request.form.get('educational'),
  832. 'blood_type': request.form.get('blood_type'),
  833. 'religion': request.form.get('religion'),
  834. 'hobbies': request.form.get('hobbies'),
  835. 'personal_achievements': request.form.get('personal_achievements'),
  836. 'family_rank': request.form.get('family_rank'),
  837. 'tags': request.form.get('tags'),
  838. 'notes': request.form.get('notes'),
  839. 'source_record_id': request.form.get('source_record_id') or None # Save source record ID
  840. }
  841. # ... (rest of logic) ...
  842. with conn.cursor() as cursor:
  843. fields = ", ".join(data.keys())
  844. placeholders = ", ".join(["%s"] * len(data))
  845. sql = f"INSERT INTO family_member_info ({fields}) VALUES ({placeholders})"
  846. cursor.execute(sql, list(data.values()))
  847. member_id = cursor.lastrowid
  848. # 录入关系
  849. if related_mid and relation_type:
  850. rel_type = int(relation_type)
  851. parent_mid = int(related_mid)
  852. child_mid = member_id
  853. gen_diff = 1 if rel_type in [1, 2] else 0
  854. sql_relation = """
  855. INSERT INTO family_relation_info
  856. (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff)
  857. VALUES (%s, %s, %s, %s, %s, %s)
  858. """
  859. cursor.execute(sql_relation, (parent_mid, child_mid, rel_type, sub_relation_type, member_id, gen_diff))
  860. # Update AI Record Status if applicable
  861. source_record_id = data.get('source_record_id')
  862. source_index = request.form.get('source_index')
  863. if source_record_id and source_index and source_index.isdigit():
  864. try:
  865. idx = int(source_index)
  866. cursor.execute("SELECT ai_content FROM genealogy_records WHERE id = %s FOR UPDATE", (source_record_id,))
  867. rec = cursor.fetchone()
  868. if rec and rec['ai_content']:
  869. import json
  870. content = json.loads(rec['ai_content'])
  871. # Ensure content is a list (it might be a dict if single object, though we try to normalize)
  872. if isinstance(content, dict):
  873. content = [content]
  874. if isinstance(content, list):
  875. updated = False
  876. if 0 <= idx < len(content):
  877. if not content[idx].get('is_imported'): # Avoid redundant updates
  878. content[idx]['is_imported'] = True
  879. content[idx]['imported_member_id'] = member_id
  880. updated = True
  881. if updated:
  882. new_content = json.dumps(content, ensure_ascii=False)
  883. cursor.execute("UPDATE genealogy_records SET ai_content = %s WHERE id = %s", (new_content, source_record_id))
  884. except Exception as e:
  885. print(f"Error updating AI content status: {e}")
  886. conn.commit()
  887. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  888. return jsonify({"success": True, "message": "成员录入成功", "member_id": member_id})
  889. flash('成员录入成功')
  890. return redirect(url_for('members'))
  891. with conn.cursor() as cursor:
  892. cursor.execute("SELECT id, name FROM family_member_info ORDER BY name")
  893. all_members = cursor.fetchall()
  894. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  895. images = cursor.fetchall()
  896. except Exception as e:
  897. flash(f'发生错误: {e}')
  898. all_members = []
  899. images = []
  900. finally:
  901. conn.close()
  902. return render_template('add_member.html', all_members=all_members, images=images,
  903. prefilled_content=prefilled_content, source_oss_url=source_oss_url, source_record_id=source_record_id)
  904. @app.route('/manager/edit_member/<int:member_id>', methods=['GET', 'POST'])
  905. def edit_member(member_id):
  906. if 'user_id' not in session:
  907. return redirect(url_for('login'))
  908. conn = get_db_connection()
  909. try:
  910. if request.method == 'POST':
  911. birthday_str = request.form.get('birthday')
  912. birthday_ts = 0
  913. if birthday_str:
  914. try:
  915. birthday_ts = int(datetime.strptime(birthday_str, '%Y-%m-%d').timestamp())
  916. except ValueError:
  917. birthday_ts = 0
  918. # 关系数据
  919. related_mid = request.form.get('related_mid')
  920. relation_type = request.form.get('relation_type')
  921. sub_relation_type = request.form.get('sub_relation_type', 0)
  922. # 年龄校验逻辑
  923. if related_mid and relation_type in ['1', '2']:
  924. with conn.cursor() as cursor:
  925. cursor.execute("SELECT name, birthday FROM family_member_info WHERE id = %s", (related_mid,))
  926. parent = cursor.fetchone()
  927. if parent and parent['birthday'] > 0 and birthday_ts > 0:
  928. if birthday_ts < parent['birthday']:
  929. flash(f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。")
  930. # 重新加载编辑页所需数据
  931. cursor.execute("SELECT * FROM family_member_info WHERE id = %s", (member_id,))
  932. member = cursor.fetchone()
  933. member['birthday_date'] = birthday_str # 保持用户输入
  934. cursor.execute("SELECT id, name FROM family_member_info WHERE id != %s ORDER BY name", (member_id,))
  935. all_members = cursor.fetchall()
  936. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  937. images = cursor.fetchall()
  938. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  939. return jsonify({
  940. "success": False,
  941. "message": f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。"
  942. }), 400
  943. return render_template('add_member.html', member=member, images=images, all_members=all_members)
  944. data = {
  945. 'name': request.form['name'],
  946. 'simplified_name': request.form.get('simplified_name'),
  947. 'former_name': request.form.get('former_name'),
  948. 'childhood_name': request.form.get('childhood_name'),
  949. 'name_word': request.form.get('name_word'),
  950. 'name_word_generation': ';'.join([g.strip() for g in request.form.getlist('lineage_generations[]') if g.strip()]),
  951. 'name_title': request.form.get('name_title'),
  952. 'sex': request.form['sex'],
  953. 'birthday': birthday_ts,
  954. 'is_pass_away': request.form.get('is_pass_away', 0),
  955. 'marital_status': request.form.get('marital_status', 0),
  956. 'birth_place': request.form.get('birth_place'),
  957. 'branch_family_hall': request.form.get('branch_family_hall'),
  958. 'cluster_place': request.form.get('cluster_place'),
  959. 'nation': request.form.get('nation'),
  960. 'residential_address': request.form.get('residential_address'),
  961. 'phone': request.form.get('phone'),
  962. 'mail': request.form.get('mail'),
  963. 'wechat_account': request.form.get('wechat_account'),
  964. 'id_number': request.form.get('id_number'),
  965. 'occupation': request.form.get('occupation'),
  966. 'educational': request.form.get('educational'),
  967. 'blood_type': request.form.get('blood_type'),
  968. 'religion': request.form.get('religion'),
  969. 'hobbies': request.form.get('hobbies'),
  970. 'personal_achievements': request.form.get('personal_achievements'),
  971. 'family_rank': request.form.get('family_rank'),
  972. 'tags': request.form.get('tags'),
  973. 'notes': request.form.get('notes'),
  974. 'source_record_id': request.form.get('source_record_id') or None
  975. }
  976. # 关系数据
  977. related_mid = request.form.get('related_mid')
  978. relation_type = request.form.get('relation_type')
  979. sub_relation_type = request.form.get('sub_relation_type', 0)
  980. with conn.cursor() as cursor:
  981. update_parts = [f"{k} = %s" for k in data.keys()]
  982. sql = f"UPDATE family_member_info SET {', '.join(update_parts)} WHERE id = %s"
  983. cursor.execute(sql, list(data.values()) + [member_id])
  984. # 更新关系
  985. if related_mid and relation_type:
  986. rel_type = int(relation_type)
  987. cursor.execute("DELETE FROM family_relation_info WHERE source_mid = %s", (member_id,))
  988. parent_mid = int(related_mid)
  989. child_mid = member_id
  990. gen_diff = 1 if rel_type in [1, 2] else 0
  991. sql_relation = """
  992. INSERT INTO family_relation_info
  993. (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff)
  994. VALUES (%s, %s, %s, %s, %s, %s)
  995. """
  996. cursor.execute(sql_relation, (parent_mid, child_mid, rel_type, sub_relation_type, member_id, gen_diff))
  997. # Update AI Record Status if applicable
  998. source_record_id = data.get('source_record_id')
  999. source_index = request.form.get('source_index')
  1000. if source_record_id and source_index and source_index.isdigit():
  1001. try:
  1002. idx = int(source_index)
  1003. cursor.execute("SELECT ai_content FROM genealogy_records WHERE id = %s FOR UPDATE", (source_record_id,))
  1004. rec = cursor.fetchone()
  1005. if rec and rec['ai_content']:
  1006. import json
  1007. content = json.loads(rec['ai_content'])
  1008. if isinstance(content, dict):
  1009. content = [content]
  1010. if isinstance(content, list):
  1011. updated = False
  1012. if 0 <= idx < len(content):
  1013. if not content[idx].get('is_imported'): # Avoid redundant updates
  1014. content[idx]['is_imported'] = True
  1015. content[idx]['imported_member_id'] = member_id
  1016. updated = True
  1017. if updated:
  1018. new_content = json.dumps(content, ensure_ascii=False)
  1019. cursor.execute("UPDATE genealogy_records SET ai_content = %s WHERE id = %s", (new_content, source_record_id))
  1020. except Exception as e:
  1021. print(f"Error updating AI content status: {e}")
  1022. conn.commit()
  1023. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  1024. return jsonify({"success": True, "message": "成员信息更新成功"})
  1025. flash('成员信息更新成功')
  1026. return redirect(url_for('members'))
  1027. with conn.cursor() as cursor:
  1028. cursor.execute("SELECT * FROM family_member_info WHERE id = %s", (member_id,))
  1029. member = cursor.fetchone()
  1030. if not member:
  1031. flash('成员不存在')
  1032. return redirect(url_for('members'))
  1033. # 格式化日期供显示
  1034. if member.get('birthday'):
  1035. member['birthday_date'] = format_timestamp(member['birthday'])
  1036. # 获取现有关系
  1037. cursor.execute("SELECT * FROM family_relation_info WHERE source_mid = %s LIMIT 1", (member_id,))
  1038. current_relation = cursor.fetchone()
  1039. cursor.execute("SELECT id, name FROM family_member_info WHERE id != %s ORDER BY name", (member_id,))
  1040. all_members = cursor.fetchall()
  1041. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  1042. images = cursor.fetchall()
  1043. finally:
  1044. conn.close()
  1045. return render_template('add_member.html', member=member, images=images, all_members=all_members, current_relation=current_relation)
  1046. @app.route('/manager/member_detail/<int:member_id>')
  1047. def member_detail(member_id):
  1048. if 'user_id' not in session:
  1049. return redirect(url_for('login'))
  1050. conn = get_db_connection()
  1051. try:
  1052. with conn.cursor() as cursor:
  1053. # Join with genealogy_records to get source image info
  1054. sql = """
  1055. SELECT m.*, r.oss_url as source_image_url, r.page_number as source_page,
  1056. r.genealogy_version, r.genealogy_source, r.upload_person
  1057. FROM family_member_info m
  1058. LEFT JOIN genealogy_records r ON m.source_record_id = r.id
  1059. WHERE m.id = %s
  1060. """
  1061. cursor.execute(sql, (member_id,))
  1062. member = cursor.fetchone()
  1063. if not member:
  1064. flash('成员不存在')
  1065. return redirect(url_for('members'))
  1066. member['birthday_str'] = format_timestamp(member.get('birthday'))
  1067. # 获取关系
  1068. cursor.execute("""
  1069. SELECT m.id, m.name, r.relation_type
  1070. FROM family_relation_info r
  1071. JOIN family_member_info m ON r.parent_mid = m.id
  1072. WHERE r.child_mid = %s
  1073. """, (member_id,))
  1074. parents = cursor.fetchall()
  1075. cursor.execute("""
  1076. SELECT m.id, m.name, r.relation_type
  1077. FROM family_relation_info r
  1078. JOIN family_member_info m ON r.child_mid = m.id
  1079. WHERE r.parent_mid = %s
  1080. """, (member_id,))
  1081. children = cursor.fetchall()
  1082. finally:
  1083. conn.close()
  1084. return render_template('member_detail.html', member=member, parents=parents, children=children)
  1085. @app.route('/manager/delete_member/<int:member_id>', methods=['POST'])
  1086. def delete_member(member_id):
  1087. if 'user_id' not in session:
  1088. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1089. conn = get_db_connection()
  1090. try:
  1091. with conn.cursor() as cursor:
  1092. # 1. 删除关系表中关联该成员的所有记录
  1093. cursor.execute("DELETE FROM family_relation_info WHERE parent_mid = %s OR child_mid = %s OR source_mid = %s",
  1094. (member_id, member_id, member_id))
  1095. # 2. 删除成员本身
  1096. cursor.execute("DELETE FROM family_member_info WHERE id = %s", (member_id,))
  1097. conn.commit()
  1098. flash('成员及其关系已成功删除')
  1099. return redirect(url_for('members'))
  1100. except Exception as e:
  1101. conn.rollback()
  1102. flash(f'删除失败: {e}')
  1103. return redirect(url_for('members'))
  1104. finally:
  1105. conn.close()
  1106. @app.route('/manager/login', methods=['GET', 'POST'])
  1107. def login():
  1108. if request.method == 'POST':
  1109. username = request.form['username']
  1110. password = request.form['password']
  1111. try:
  1112. conn = get_db_connection()
  1113. try:
  1114. with conn.cursor() as cursor:
  1115. cursor.execute("SELECT * FROM users WHERE username=%s AND password=%s", (username, password))
  1116. user = cursor.fetchone()
  1117. if user:
  1118. session['user_id'] = user['id']
  1119. session['username'] = user['username']
  1120. return redirect(url_for('index'))
  1121. else:
  1122. flash('用户名或密码错误')
  1123. finally:
  1124. conn.close()
  1125. except Exception as e:
  1126. flash(f'数据库连接错误: {str(e)}')
  1127. print(f'Login error: {str(e)}')
  1128. return render_template('login.html')
  1129. @app.route('/manager/logout')
  1130. def logout():
  1131. session.clear()
  1132. return redirect(url_for('login'))
  1133. @app.route('/manager/api/check_name')
  1134. def check_name():
  1135. if 'user_id' not in session:
  1136. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1137. name = request.args.get('name', '').strip()
  1138. if not name:
  1139. return jsonify({"success": True, "exists": False})
  1140. conn = get_db_connection()
  1141. try:
  1142. with conn.cursor() as cursor:
  1143. # Check for name or simplified_name match
  1144. cursor.execute("SELECT id, name, simplified_name, sex, birthday, is_pass_away FROM family_member_info WHERE name = %s OR simplified_name = %s", (name, name))
  1145. matches = cursor.fetchall()
  1146. if matches:
  1147. # Format birthday for display
  1148. for m in matches:
  1149. if m.get('birthday'):
  1150. m['birthday_str'] = format_timestamp(m['birthday'])
  1151. else:
  1152. m['birthday_str'] = '未知'
  1153. return jsonify({"success": True, "exists": True, "matches": matches})
  1154. else:
  1155. return jsonify({"success": True, "exists": False})
  1156. except Exception as e:
  1157. return jsonify({"success": False, "error": str(e)}), 500
  1158. finally:
  1159. conn.close()
  1160. import requests
  1161. import json
  1162. import re
  1163. @app.route('/manager/api/recognize_image', methods=['POST'])
  1164. def recognize_image():
  1165. if 'user_id' not in session:
  1166. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1167. data = request.json
  1168. image_url = data.get('image_url')
  1169. if not image_url:
  1170. return jsonify({"success": False, "message": "No image URL provided"}), 400
  1171. api_key = "a1800657-9212-4afe-9b7c-b49f015c54d3"
  1172. api_url = "https://ark.cn-beijing.volces.com/api/v3/responses"
  1173. prompt = """
  1174. 请分析这张家谱图片,提取其中关于人物的信息。
  1175. 请务必将繁体字转换为简体字(original_name 字段除外)。
  1176. 特别注意:'name' 字段必须是纯简体中文,不能包含繁体字(例如:'學'应转换为'学','劉'应转换为'刘','萬'应转换为'万')。
  1177. 请提取以下字段(如果存在):
  1178. - original_name: 原始姓名(严格保持图片上的繁体字,不做任何修改或转换)
  1179. - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
  1180. - sex: 性别(男/女)
  1181. - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
  1182. - death_date: 逝世日期(如文本中出现“殁”、“葬”、“卒”等字眼及其对应的时间,请提取)
  1183. - father_name: 父亲姓名
  1184. - spouse_name: 配偶姓名
  1185. - generation: 第几世/代数
  1186. - name_word: 字辈(例如名字为“学勤公”,“学”为字辈;提取名字中的字辈信息)
  1187. - education: 学历/功名
  1188. - title: 官职/称号
  1189. 请严格以JSON列表格式返回,不要包含Markdown代码块标记(如 ```json ... ```),直接返回JSON数组。
  1190. 如果包含多个人物,请都提取出来。
  1191. """
  1192. ai_payload_url = get_normalized_base64_image(image_url)
  1193. payload = {
  1194. "model": "doubao-seed-1-8-251228",
  1195. "stream": True,
  1196. "input": [
  1197. {
  1198. "role": "user",
  1199. "content": [
  1200. {
  1201. "type": "input_image",
  1202. "image_url": ai_payload_url
  1203. },
  1204. {
  1205. "type": "input_text",
  1206. "text": prompt
  1207. }
  1208. ]
  1209. }
  1210. ]
  1211. }
  1212. headers = {
  1213. "Authorization": f"Bearer {api_key}",
  1214. "Content-Type": "application/json"
  1215. }
  1216. def generate():
  1217. yield "正在连接 AI 服务...\n"
  1218. try:
  1219. # 使用 stream=True, timeout=120
  1220. # 增加 verify=False 以防 SSL 问题(开发环境)
  1221. # 增加 proxies=None 以防本地代理干扰
  1222. with requests.post(
  1223. api_url,
  1224. json=payload,
  1225. headers=headers,
  1226. stream=True,
  1227. timeout=1200,
  1228. verify=False,
  1229. proxies={"http": None, "https": None}
  1230. ) as r:
  1231. if r.status_code != 200:
  1232. yield f"Error: API returned status code {r.status_code}. Response: {r.text}"
  1233. return
  1234. yield "连接成功,正在等待 AI 响应...\n"
  1235. full_reasoning = ""
  1236. json_started = False
  1237. for line in r.iter_lines():
  1238. if line:
  1239. line_str = line.decode('utf-8')
  1240. if line_str.startswith('data: '):
  1241. json_str = line_str[6:]
  1242. if json_str.strip() == '[DONE]':
  1243. break
  1244. try:
  1245. chunk = json.loads(json_str)
  1246. # 处理 standard OpenAI choices format (content)
  1247. if 'choices' in chunk and len(chunk['choices']) > 0:
  1248. delta = chunk['choices'][0].get('delta', {})
  1249. if 'content' in delta:
  1250. if not json_started:
  1251. yield "|||JSON_START|||"
  1252. json_started = True
  1253. yield delta['content']
  1254. # 处理 standard OpenAI choices format (reasoning_content) if any
  1255. if 'reasoning_content' in delta:
  1256. yield f"\n[推理]: {delta['reasoning_content']}"
  1257. # 处理 Doubao/Volcano specific formats
  1258. # Type: response.reasoning_summary_text.delta
  1259. if chunk.get('type') == 'response.reasoning_summary_text.delta':
  1260. if 'delta' in chunk:
  1261. yield chunk['delta']
  1262. # Type: response.text.delta
  1263. if chunk.get('type') == 'response.text.delta':
  1264. if 'delta' in chunk:
  1265. if not json_started:
  1266. yield "|||JSON_START|||"
  1267. json_started = True
  1268. yield chunk['delta']
  1269. # Type: response.output_item.added (May contain initial content or status)
  1270. # Type: response.reasoning_summary_part.added
  1271. except Exception as e:
  1272. print(f"Chunk parse error: {e}")
  1273. else:
  1274. # 尝试直接解析非 data: 开头的行
  1275. try:
  1276. chunk = json.loads(line_str)
  1277. if 'choices' in chunk and len(chunk['choices']) > 0:
  1278. content = chunk['choices'][0]['message']['content']
  1279. yield content
  1280. except:
  1281. pass
  1282. except Exception as e:
  1283. yield f"\n[Error: {str(e)}]"
  1284. return Response(stream_with_context(generate()), mimetype='text/plain')
  1285. @app.route('/manager/api/start_analysis/<int:record_id>', methods=['POST'])
  1286. def start_analysis(record_id):
  1287. if 'user_id' not in session:
  1288. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1289. conn = get_db_connection()
  1290. try:
  1291. with conn.cursor() as cursor:
  1292. # Check if record exists
  1293. cursor.execute("SELECT oss_url, ai_status FROM genealogy_records WHERE id = %s", (record_id,))
  1294. record = cursor.fetchone()
  1295. if not record:
  1296. return jsonify({"success": False, "message": "Record not found"}), 404
  1297. # Update status to processing (1)
  1298. cursor.execute("UPDATE genealogy_records SET ai_status = 1 WHERE id = %s", (record_id,))
  1299. conn.commit()
  1300. # Start background task
  1301. threading.Thread(target=process_ai_task, args=(record_id, record['oss_url'])).start()
  1302. return jsonify({"success": True, "message": "Analysis started"})
  1303. except Exception as e:
  1304. return jsonify({"success": False, "message": str(e)}), 500
  1305. finally:
  1306. conn.close()
  1307. def process_files_background(upload_folder, saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person):
  1308. current_suggested_page = int(manual_page) if manual_page and str(manual_page).isdigit() else suggested_page
  1309. ensure_pdf_table()
  1310. for item in saved_files:
  1311. if len(item) >= 4:
  1312. filename, file_path, file_page, original_filename = item[0], item[1], item[2], item[3]
  1313. elif len(item) == 3:
  1314. filename, file_path, file_page = item
  1315. original_filename = filename
  1316. else:
  1317. filename, file_path = item[0], item[1]
  1318. file_page = None
  1319. original_filename = filename
  1320. try:
  1321. if filename.lower().endswith('.pdf'):
  1322. import uuid
  1323. display_pdf_name = (original_filename or filename).strip() or filename
  1324. oss_pdf_name = secure_filename(display_pdf_name)
  1325. if not oss_pdf_name or not oss_pdf_name.lower().endswith('.pdf'):
  1326. oss_pdf_name = f"genealogy_pdf_{uuid.uuid4().hex[:8]}.pdf"
  1327. pdf_oss_url = upload_to_oss(file_path, custom_filename=oss_pdf_name)
  1328. if pdf_oss_url:
  1329. desc_parts = []
  1330. if genealogy_version:
  1331. desc_parts.append(genealogy_version)
  1332. if genealogy_source:
  1333. desc_parts.append(genealogy_source)
  1334. pdf_description = ' · '.join(desc_parts) if desc_parts else ''
  1335. conn_pdf = get_db_connection()
  1336. try:
  1337. with conn_pdf.cursor() as cursor:
  1338. cursor.execute(
  1339. "INSERT INTO genealogy_pdfs (file_name, oss_url, description, uploader) VALUES (%s, %s, %s, %s)",
  1340. (display_pdf_name, pdf_oss_url, pdf_description, upload_person or '')
  1341. )
  1342. conn_pdf.commit()
  1343. except Exception as pdf_meta_e:
  1344. print(f"Error inserting genealogy_pdfs for {display_pdf_name}: {pdf_meta_e}")
  1345. finally:
  1346. conn_pdf.close()
  1347. else:
  1348. print(f"Warning: full PDF upload to OSS failed for {filename}, scan pages will still be processed.")
  1349. doc = fitz.open(file_path)
  1350. for page_index in range(len(doc)):
  1351. img_path = None
  1352. try:
  1353. page = doc.load_page(page_index)
  1354. max_dim = max(page.rect.width, page.rect.height)
  1355. zoom = 2000 / max_dim if max_dim > 0 else 2.0
  1356. if zoom > 2.5: zoom = 2.5
  1357. mat = fitz.Matrix(zoom, zoom)
  1358. # Use get_pixmap with matrix directly
  1359. pix = page.get_pixmap(matrix=mat)
  1360. final_page = current_suggested_page
  1361. if genealogy_version and genealogy_source:
  1362. if final_page is not None and str(final_page).strip() != '':
  1363. img_filename = f"{genealogy_version}_{genealogy_source}_{final_page}.jpg"
  1364. else:
  1365. img_filename = f"{genealogy_version}_{genealogy_source}.jpg"
  1366. else:
  1367. img_filename = f"{os.path.splitext(filename)[0]}_page_{page_index+1}.jpg"
  1368. img_path = os.path.join(upload_folder, img_filename)
  1369. # Save the pixmap to the image path
  1370. pix.save(img_path)
  1371. oss_url = upload_to_oss(img_path, custom_filename=img_filename)
  1372. if oss_url:
  1373. conn = get_db_connection()
  1374. try:
  1375. with conn.cursor() as cursor:
  1376. sql = """INSERT INTO genealogy_records
  1377. (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type)
  1378. VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
  1379. cursor.execute(sql, (img_filename, oss_url, final_page, genealogy_version, genealogy_source, upload_person, 'PDF'))
  1380. record_id = cursor.lastrowid
  1381. conn.commit()
  1382. threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
  1383. current_suggested_page += 1
  1384. finally:
  1385. conn.close()
  1386. except Exception as page_e:
  1387. print(f"Error processing page {page_index} of {filename}: {page_e}")
  1388. finally:
  1389. if img_path and os.path.exists(img_path):
  1390. try:
  1391. os.remove(img_path)
  1392. except:
  1393. pass
  1394. doc.close()
  1395. else:
  1396. img_path = compress_image_if_needed(file_path)
  1397. # Use explicitly set page number if provided, otherwise extract from filename or auto-increment
  1398. if file_page and str(file_page).isdigit():
  1399. final_page = int(file_page)
  1400. current_suggested_page = final_page + 1
  1401. page_num = final_page
  1402. else:
  1403. page_num = extract_page_number(img_path)
  1404. final_page = page_num if page_num else current_suggested_page
  1405. ext = os.path.splitext(img_path)[1]
  1406. if genealogy_version and genealogy_source:
  1407. if final_page is not None and str(final_page).strip() != '':
  1408. img_filename = f"{genealogy_version}_{genealogy_source}_{final_page}{ext}"
  1409. else:
  1410. img_filename = f"{genealogy_version}_{genealogy_source}{ext}"
  1411. else:
  1412. img_filename = os.path.basename(img_path)
  1413. oss_url = upload_to_oss(img_path, custom_filename=img_filename)
  1414. if oss_url:
  1415. conn = get_db_connection()
  1416. try:
  1417. with conn.cursor() as cursor:
  1418. sql = """INSERT INTO genealogy_records
  1419. (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type)
  1420. VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
  1421. cursor.execute(sql, (img_filename, oss_url, final_page, genealogy_version, genealogy_source, upload_person, '图片'))
  1422. record_id = cursor.lastrowid
  1423. conn.commit()
  1424. threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
  1425. if page_num:
  1426. current_suggested_page = page_num + 1
  1427. else:
  1428. current_suggested_page += 1
  1429. finally:
  1430. conn.close()
  1431. if img_path and img_path != file_path and os.path.exists(img_path):
  1432. try:
  1433. os.remove(img_path)
  1434. except:
  1435. pass
  1436. except Exception as e:
  1437. print(f"Error processing file {filename}: {e}")
  1438. finally:
  1439. if os.path.exists(file_path):
  1440. try:
  1441. os.remove(file_path)
  1442. except:
  1443. pass
  1444. @app.route('/manager/upload', methods=['GET', 'POST'])
  1445. def upload():
  1446. if 'user_id' not in session:
  1447. return redirect(url_for('login'))
  1448. # 获取建议页码 (当前最大页码 + 1)
  1449. conn = get_db_connection()
  1450. suggested_page = 1
  1451. try:
  1452. with conn.cursor() as cursor:
  1453. cursor.execute("SELECT MAX(page_number) as max_p FROM genealogy_records")
  1454. result = cursor.fetchone()
  1455. if result and result['max_p']:
  1456. suggested_page = result['max_p'] + 1
  1457. finally:
  1458. conn.close()
  1459. if request.method == 'POST':
  1460. if 'file' not in request.files:
  1461. flash('未选择文件')
  1462. return redirect(request.url)
  1463. files = request.files.getlist('file')
  1464. if not files or files[0].filename == '':
  1465. flash('未选择文件')
  1466. return redirect(request.url)
  1467. manual_page = request.form.get('manual_page')
  1468. genealogy_version = request.form.get('genealogy_version', '')
  1469. genealogy_source = request.form.get('genealogy_source', '')
  1470. upload_person = request.form.get('upload_person', '')
  1471. if not upload_person:
  1472. upload_person = session.get('username', '')
  1473. import uuid
  1474. saved_files = []
  1475. for i, file in enumerate(files):
  1476. if not file or not file.filename:
  1477. continue
  1478. original_filename = file.filename
  1479. ext = os.path.splitext(original_filename)[1].lower()
  1480. base_name = secure_filename(original_filename)
  1481. # If secure_filename removes all characters (e.g., pure Chinese name) or just leaves 'pdf'
  1482. if not base_name or base_name == ext.strip('.'):
  1483. filename = f"upload_{uuid.uuid4().hex[:8]}{ext}"
  1484. else:
  1485. # Ensure the extension is preserved
  1486. if not base_name.lower().endswith(ext):
  1487. filename = f"{base_name}{ext}"
  1488. else:
  1489. filename = base_name
  1490. file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
  1491. file.save(file_path)
  1492. # Fetch individual page number if it exists
  1493. file_page = request.form.get(f'page_number_{i}')
  1494. saved_files.append((filename, file_path, file_page, original_filename))
  1495. if saved_files:
  1496. threading.Thread(
  1497. target=process_files_background,
  1498. args=(app.config['UPLOAD_FOLDER'], saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person)
  1499. ).start()
  1500. flash('上传完成,AI解析中,稍后查看')
  1501. time.sleep(1.5)
  1502. return redirect(url_for('index'))
  1503. return render_template('upload.html', suggested_page=suggested_page)
  1504. @app.route('/manager/save_upload', methods=['POST'])
  1505. def save_upload():
  1506. if 'user_id' not in session: return redirect(url_for('login'))
  1507. filename = request.form.get('filename')
  1508. oss_url = request.form.get('oss_url')
  1509. page_number = request.form.get('page_number')
  1510. genealogy_version = request.form.get('genealogy_version', '')
  1511. genealogy_source = request.form.get('genealogy_source', '')
  1512. upload_person = request.form.get('upload_person', session.get('username', ''))
  1513. file_type = request.form.get('file_type', '图片')
  1514. if not oss_url or not page_number:
  1515. flash('页码不能为空')
  1516. return redirect(url_for('upload'))
  1517. conn = get_db_connection()
  1518. try:
  1519. with conn.cursor() as cursor:
  1520. sql = """INSERT INTO genealogy_records
  1521. (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type)
  1522. VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
  1523. cursor.execute(sql, (filename, oss_url, page_number, genealogy_version, genealogy_source, upload_person, file_type))
  1524. record_id = cursor.lastrowid
  1525. conn.commit()
  1526. # Start AI Task
  1527. threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
  1528. flash('上传完成,AI解析中,稍后查看')
  1529. except Exception as e:
  1530. flash(f'保存失败: {e}')
  1531. finally:
  1532. conn.close()
  1533. return redirect(url_for('index'))
  1534. @app.route('/manager/delete_upload/<int:record_id>', methods=['POST'])
  1535. def delete_upload(record_id):
  1536. if 'user_id' not in session:
  1537. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1538. conn = get_db_connection()
  1539. try:
  1540. with conn.cursor() as cursor:
  1541. # 删除记录
  1542. cursor.execute("DELETE FROM genealogy_records WHERE id = %s", (record_id,))
  1543. conn.commit()
  1544. flash('文件记录已成功删除')
  1545. return redirect(url_for('index'))
  1546. except Exception as e:
  1547. conn.rollback()
  1548. flash(f'删除失败: {e}')
  1549. return redirect(url_for('index'))
  1550. finally:
  1551. conn.close()
  1552. @app.route('/manager/upload_pdf', methods=['POST'])
  1553. def upload_pdf():
  1554. if 'user_id' not in session:
  1555. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1556. if 'file' not in request.files:
  1557. return jsonify({"success": False, "message": "未选择文件"}), 400
  1558. file = request.files['file']
  1559. if file.filename == '':
  1560. return jsonify({"success": False, "message": "未选择文件"}), 400
  1561. if not file.filename.lower().endswith('.pdf'):
  1562. return jsonify({"success": False, "message": "请上传PDF文件"}), 400
  1563. import uuid
  1564. original_filename = file.filename
  1565. ext = os.path.splitext(original_filename)[1].lower()
  1566. base_name = secure_filename(original_filename)
  1567. if not base_name or base_name == ext.strip('.'):
  1568. filename = f"genealogy_pdf_{uuid.uuid4().hex[:8]}{ext}"
  1569. else:
  1570. if not base_name.lower().endswith(ext):
  1571. filename = f"{base_name}{ext}"
  1572. else:
  1573. filename = base_name
  1574. file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
  1575. file.save(file_path)
  1576. try:
  1577. # Upload to OSS
  1578. oss_url = upload_to_oss(file_path, custom_filename=filename)
  1579. if not oss_url:
  1580. return jsonify({"success": False, "message": "文件上传失败"}), 500
  1581. # Get form data
  1582. uploader = request.form.get('uploader', session.get('username', ''))
  1583. version_name = request.form.get('version_name', '')
  1584. version_source = request.form.get('version_source', '')
  1585. file_provider = request.form.get('file_provider', uploader)
  1586. # Save to database
  1587. conn = get_db_connection()
  1588. try:
  1589. with conn.cursor() as cursor:
  1590. cursor.execute(
  1591. "INSERT INTO genealogy_pdfs (file_name, oss_url, uploader, version_name, version_source, file_provider) VALUES (%s, %s, %s, %s, %s, %s)",
  1592. (original_filename, oss_url, uploader, version_name, version_source, file_provider)
  1593. )
  1594. conn.commit()
  1595. # Start background processing for PDF pages
  1596. threading.Thread(
  1597. target=process_pdf_pages,
  1598. args=(file_path, oss_url, uploader)
  1599. ).start()
  1600. return jsonify({"success": True, "message": "PDF文件上传成功,正在解析页面"})
  1601. except Exception as e:
  1602. return jsonify({"success": False, "message": f"保存失败: {e}"}), 500
  1603. finally:
  1604. conn.close()
  1605. finally:
  1606. if os.path.exists(file_path):
  1607. try:
  1608. os.remove(file_path)
  1609. except:
  1610. pass
  1611. def process_pdf_pages(file_path, pdf_oss_url, uploader):
  1612. """Process PDF pages and add them to genealogy records"""
  1613. try:
  1614. import fitz
  1615. doc = fitz.open(file_path)
  1616. # Get current max page number
  1617. conn = get_db_connection()
  1618. suggested_page = 1
  1619. try:
  1620. with conn.cursor() as cursor:
  1621. cursor.execute("SELECT MAX(page_number) as max_p FROM genealogy_records")
  1622. result = cursor.fetchone()
  1623. if result and result['max_p']:
  1624. suggested_page = result['max_p'] + 1
  1625. finally:
  1626. conn.close()
  1627. for page_index in range(len(doc)):
  1628. try:
  1629. page = doc[page_index]
  1630. pix = page.get_pixmap(dpi=150)
  1631. # Save as image
  1632. img_filename = f"{os.path.splitext(os.path.basename(file_path))[0]}_page_{page_index+1}.jpg"
  1633. img_path = os.path.join(app.config['UPLOAD_FOLDER'], img_filename)
  1634. pix.save(img_path)
  1635. # Upload to OSS
  1636. img_oss_url = upload_to_oss(img_path, custom_filename=img_filename)
  1637. if img_oss_url:
  1638. # Save to genealogy_records
  1639. conn = get_db_connection()
  1640. try:
  1641. with conn.cursor() as cursor:
  1642. cursor.execute(
  1643. "INSERT INTO genealogy_records (file_name, oss_url, page_number, ai_status, upload_person, file_type) VALUES (%s, %s, %s, 1, %s, %s)",
  1644. (img_filename, img_oss_url, suggested_page + page_index, uploader, '图片')
  1645. )
  1646. record_id = cursor.lastrowid
  1647. conn.commit()
  1648. # Start AI processing
  1649. threading.Thread(target=process_ai_task, args=(record_id, img_oss_url)).start()
  1650. finally:
  1651. conn.close()
  1652. except Exception as e:
  1653. print(f"Error processing page {page_index+1}: {e}")
  1654. finally:
  1655. if 'img_path' in locals() and os.path.exists(img_path):
  1656. try:
  1657. os.remove(img_path)
  1658. except:
  1659. pass
  1660. except Exception as e:
  1661. print(f"Error processing PDF: {e}")
  1662. if __name__ == '__main__':
  1663. app.run(debug=False, port=5001)