app.py 125 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809
  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": "root",
  26. "password": "csqz@20255",
  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. # 尝试使用数据库连接池,如果不可用则使用普通连接
  53. try:
  54. from DBUtils.PooledDB import PooledDB
  55. # 创建连接池
  56. pool = PooledDB(
  57. creator=pymysql,
  58. maxconnections=10, # 连接池最大连接数
  59. mincached=2, # 初始化时创建的空闲连接数
  60. maxcached=5, # 最大空闲连接数
  61. maxshared=3, # 最大共享连接数
  62. blocking=True, # 连接池满时是否阻塞等待
  63. maxusage=1000, # 一个连接最多被重复使用的次数,防止连接长时间使用失效
  64. setsession=[], # 开始会话前执行的命令列表
  65. ping=1, # 每次获取连接时都检查连接是否可用
  66. **DB_CONFIG
  67. )
  68. def get_db_connection():
  69. conn = pool.connection()
  70. print(f"[Database] Got connection from pool: {id(conn)}")
  71. return conn
  72. print("[Database] Database connection pool initialized successfully")
  73. except ImportError:
  74. # 如果DBUtils不可用,使用普通连接
  75. def get_db_connection():
  76. conn = pymysql.connect(**DB_CONFIG)
  77. print(f"[Database] Created new connection: {id(conn)}")
  78. return conn
  79. print("[Database] DBUtils not available, using regular database connections")
  80. def verify_connection(conn):
  81. """Verify database connection is still alive"""
  82. try:
  83. cursor = conn.cursor()
  84. cursor.execute("SELECT 1")
  85. cursor.fetchone()
  86. cursor.close()
  87. return True
  88. except Exception as e:
  89. print(f"[Database] Connection verification failed: {e}")
  90. return False
  91. def safe_commit(conn):
  92. """Safely commit transaction with error handling"""
  93. try:
  94. conn.commit()
  95. print(f"[Database] Transaction committed successfully")
  96. return True
  97. except Exception as e:
  98. print(f"[Database] Commit failed: {e}")
  99. try:
  100. conn.rollback()
  101. print(f"[Database] Rollback completed")
  102. except Exception as rollback_err:
  103. print(f"[Database] Rollback also failed: {rollback_err}")
  104. return False
  105. def format_timestamp(ts):
  106. if not ts: return '未知'
  107. try:
  108. # 兼容秒和毫秒
  109. if ts > 10000000000: # 超过2286年的秒数,通常认为是毫秒
  110. ts = ts / 1000
  111. return time.strftime('%Y-%m-%d', time.localtime(ts))
  112. except:
  113. return '未知'
  114. def manual_simplify(text):
  115. """
  116. Simple fallback for common Traditional to Simplified conversion
  117. if AI fails to convert specific characters.
  118. """
  119. if not text: return text
  120. mapping = {
  121. '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
  122. '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
  123. '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
  124. '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
  125. '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
  126. '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
  127. '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
  128. '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
  129. '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
  130. '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
  131. '銳': '锐', '優': '优', '楊': '杨', '吳': '吴', '銀': '银'
  132. }
  133. result = ""
  134. for char in text:
  135. result += mapping.get(char, char)
  136. return result
  137. def _build_reverse_simplify_map():
  138. """
  139. Build a reverse map from simplified char -> list of traditional chars
  140. based on the fallback manual_simplify mapping.
  141. """
  142. mapping = {
  143. '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
  144. '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
  145. '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
  146. '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
  147. '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
  148. '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
  149. '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
  150. '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
  151. '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
  152. '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
  153. '銳': '锐', '優': '优', '楊': '杨', '吳': '吴', '銀': '银'
  154. }
  155. rev = {}
  156. for trad, simp in mapping.items():
  157. rev.setdefault(simp, [])
  158. if trad not in rev[simp]:
  159. rev[simp].append(trad)
  160. return rev
  161. _REVERSE_SIMPLIFY_MAP = _build_reverse_simplify_map()
  162. def expand_name_search_variants(keyword, max_variants=60):
  163. """
  164. Expand keyword into a small set of variants so Simplified/Traditional
  165. searches can match both `name` and `simplified_name`.
  166. - Always includes original keyword
  167. - Includes fallback-trad->simp conversion
  168. - Includes best-effort simp->trad expansions based on reverse map
  169. """
  170. if not keyword:
  171. return []
  172. kw = str(keyword).strip()
  173. if not kw:
  174. return []
  175. variants = set([kw])
  176. variants.add(manual_simplify(kw))
  177. # Build possible traditional variants when the input is simplified.
  178. # For each char, if we have traditional candidates, branch; otherwise keep itself.
  179. choices = []
  180. for ch in kw:
  181. cand = _REVERSE_SIMPLIFY_MAP.get(ch)
  182. if cand:
  183. # include itself too (covers already-traditional or neutral chars)
  184. choices.append([ch] + cand)
  185. else:
  186. choices.append([ch])
  187. # Cartesian product with early stop.
  188. results = ['']
  189. for opts in choices:
  190. new_results = []
  191. for prefix in results:
  192. for opt in opts:
  193. new_results.append(prefix + opt)
  194. if len(new_results) >= max_variants:
  195. break
  196. if len(new_results) >= max_variants:
  197. break
  198. results = new_results
  199. if len(results) >= max_variants:
  200. break
  201. for r in results:
  202. if r:
  203. variants.add(r)
  204. variants.add(manual_simplify(r))
  205. # Keep deterministic order for stable SQL params
  206. ordered = []
  207. for v in variants:
  208. v2 = (v or '').strip()
  209. if v2 and v2 not in ordered:
  210. ordered.append(v2)
  211. if len(ordered) >= max_variants:
  212. break
  213. return ordered
  214. def clean_name(name):
  215. """
  216. Clean name according to Liu family genealogy rules:
  217. 1. If name is '学公' or '留学公', keep 'Gong' (exception).
  218. 2. Otherwise, if name ends with '公', remove '公'.
  219. 3. If name does not start with '留', prepend '留'.
  220. """
  221. if not name: return name
  222. name = name.strip()
  223. # Pre-process: Ensure Simplified Chinese for specific chars
  224. name = manual_simplify(name)
  225. # 1. Check exceptions (names that SHOULD keep 'Gong')
  226. exceptions = ['学公', '留学公']
  227. if name in exceptions:
  228. if not name.startswith('留'):
  229. name = '留' + name
  230. return name
  231. # 2. General Rule: Remove 'Gong' suffix
  232. if name.endswith('公'):
  233. name = name[:-1]
  234. # 3. Ensure 'Liu' surname
  235. if not name.startswith('留'):
  236. name = '留' + name
  237. return name
  238. def is_female_value(sex_value):
  239. """Return True when sex value represents female."""
  240. if sex_value is None:
  241. return False
  242. s = str(sex_value).strip().lower()
  243. return s in ('女', '2', 'female', 'f')
  244. def normalize_lookup_name(name):
  245. """Normalize names for loose matching in AI parsed content."""
  246. if not name:
  247. return ''
  248. return manual_simplify(str(name)).strip()
  249. def should_skip_liu_prefix_for_person(person, spouse_name_set):
  250. """
  251. Female spouse records should not auto-prepend '留' in simplified_name.
  252. We treat a person as female spouse if:
  253. 1) sex is female, and
  254. 2) has spouse_name field OR appears in another person's spouse_name list.
  255. """
  256. if not isinstance(person, dict):
  257. return False
  258. if not is_female_value(person.get('sex')):
  259. return False
  260. own_names = set()
  261. own_names.add(normalize_lookup_name(person.get('name')))
  262. own_names.add(normalize_lookup_name(person.get('original_name')))
  263. own_names.discard('')
  264. has_spouse_name = bool(normalize_lookup_name(person.get('spouse_name')))
  265. referenced_by_other = any(n in spouse_name_set for n in own_names)
  266. return has_spouse_name or referenced_by_other
  267. def get_normalized_base64_image(image_url):
  268. """Download image, normalize to JPEG, and return base64 data URI for AI payload."""
  269. import io
  270. import base64
  271. import requests
  272. from PIL import Image
  273. try:
  274. response = requests.get(image_url, timeout=30)
  275. response.raise_for_status()
  276. with Image.open(io.BytesIO(response.content)) as img:
  277. # Convert to RGB to ensure JPEG compatibility
  278. if img.mode != 'RGB':
  279. img = img.convert('RGB')
  280. # Resize if too large
  281. max_dim = 2000
  282. if max(img.width, img.height) > max_dim:
  283. ratio = max_dim / max(img.width, img.height)
  284. new_size = (int(img.width * ratio), int(img.height * ratio))
  285. img = img.resize(new_size, Image.Resampling.LANCZOS)
  286. # Save as JPEG in memory
  287. buffer = io.BytesIO()
  288. img.save(buffer, format='JPEG', quality=85)
  289. b64_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
  290. return f"data:image/jpeg;base64,{b64_str}"
  291. except Exception as e:
  292. print(f"Error normalizing image from {image_url}: {e}")
  293. return image_url # Fallback to original URL if processing fails
  294. def process_ai_task(record_id, image_url):
  295. """Background task to process image with AI and store result."""
  296. print(f"[AI Task] Starting task for record {record_id}...")
  297. conn = get_db_connection()
  298. try:
  299. with conn.cursor() as cursor:
  300. cursor.execute("UPDATE genealogy_records SET ai_status = 1 WHERE id = %s", (record_id,))
  301. conn.commit()
  302. print(f"[AI Task] Status updated to 'Processing' for record {record_id}")
  303. api_key = "a1800657-9212-4afe-9b7c-b49f015c54d3"
  304. api_url = "https://ark.cn-beijing.volces.com/api/v3/responses"
  305. prompt = """
  306. 请分析这张家谱图片,提取其中关于人物的信息。
  307. 请务必将繁体字转换为简体字(original_name 字段除外)。
  308. 特别注意:'name' 字段必须是纯简体中文,不能包含繁体字(例如:'學'应转换为'学','劉'应转换为'刘','萬'应转换为'万')。
  309. 请提取以下字段(如果存在):
  310. - original_name: 原始姓名(严格保持图片上的繁体字,不做任何修改或转换)
  311. - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
  312. - sex: 性别(男/女)
  313. - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
  314. - death_date: 逝世日期(如文本中出现“殁”、“葬”、“卒”等字眼及其对应的时间,请提取)
  315. - father_name: 父亲姓名
  316. - spouse_name: 配偶姓名
  317. - generation: 第几世/代数
  318. - name_word: 字辈(例如名字为“学勤公”,“学”为字辈;提取名字中的字辈信息)
  319. - education: 学历/功名
  320. - title: 官职/称号
  321. 请严格以JSON列表格式返回,不要包含Markdown代码块标记(如 ```json ... ```),直接返回JSON数组。
  322. 如果包含多个人物,请都提取出来。
  323. Do not output any reasoning or explanation, just the JSON.
  324. """
  325. ai_payload_url = get_normalized_base64_image(image_url)
  326. payload = {
  327. "model": "doubao-seed-1-8-251228",
  328. "stream": True, # Streaming for robust handling
  329. "input": [
  330. {
  331. "role": "user",
  332. "content": [
  333. {"type": "input_image", "image_url": ai_payload_url},
  334. {"type": "input_text", "text": prompt}
  335. ]
  336. }
  337. ]
  338. }
  339. headers = {
  340. "Authorization": f"Bearer {api_key}",
  341. "Content-Type": "application/json"
  342. }
  343. max_retries = 3
  344. last_exception = None
  345. for attempt in range(max_retries):
  346. try:
  347. print(f"[AI Task] Attempt {attempt+1}/{max_retries} connecting to API for record {record_id}...")
  348. response = requests.post(
  349. api_url,
  350. json=payload,
  351. headers=headers,
  352. timeout=1200,
  353. stream=True,
  354. verify=False,
  355. proxies={"http": None, "https": None}
  356. )
  357. if response.status_code == 200:
  358. print(f"[AI Task] Connection established for record {record_id}, receiving stream...")
  359. full_content = ""
  360. for line in response.iter_lines():
  361. if not line: continue
  362. line_str = line.decode('utf-8')
  363. # Debug: Print full line to understand event flow
  364. print(f"[AI Task Debug] Raw Line: {line_str[:500]}") # Truncate very long lines
  365. if line_str.startswith('data: '):
  366. json_str = line_str[6:]
  367. if json_str.strip() == '[DONE]':
  368. print("[AI Task Debug] Received [DONE]")
  369. break
  370. try:
  371. chunk = json.loads(json_str)
  372. chunk_type = chunk.get('type')
  373. # Standard OpenAI format (choices)
  374. if 'choices' in chunk and len(chunk['choices']) > 0:
  375. delta = chunk['choices'][0].get('delta', {})
  376. if 'content' in delta:
  377. full_content += delta['content']
  378. # Doubao/Volcengine specific formats (delta)
  379. elif chunk_type == 'response.text.delta':
  380. full_content += chunk.get('delta', '')
  381. # Check response.completed if empty
  382. elif chunk_type == 'response.completed' and not full_content:
  383. output = chunk.get('response', {}).get('output', [])
  384. for item in output:
  385. # Also extract from reasoning if it contains JSON-like text
  386. if item.get('type') == 'reasoning':
  387. summary = item.get('summary', [])
  388. for sum_item in summary:
  389. if sum_item.get('type') == 'summary_text':
  390. full_content += sum_item.get('text', '')
  391. elif item.get('type') == 'message':
  392. content = item.get('content')
  393. if isinstance(content, str):
  394. full_content += content
  395. elif isinstance(content, list):
  396. for part in content:
  397. if isinstance(part, dict) and part.get('type') == 'text':
  398. full_content += part.get('text', '')
  399. # Fallback: output_item.added
  400. elif chunk_type == 'response.output_item.added':
  401. item = chunk.get('item', {})
  402. if item.get('role') == 'assistant':
  403. content_field = item.get('content', [])
  404. if isinstance(content_field, str):
  405. full_content += content_field
  406. elif isinstance(content_field, list):
  407. for part in content_field:
  408. if isinstance(part, dict) and part.get('type') == 'text':
  409. full_content += part.get('text', '')
  410. except Exception as e:
  411. print(f"[AI Task] Chunk parse error: {e}")
  412. else:
  413. # Fallback for non-SSE
  414. try:
  415. chunk = json.loads(line_str)
  416. if 'choices' in chunk and len(chunk['choices']) > 0:
  417. content = chunk['choices'][0]['message']['content']
  418. full_content += content
  419. except:
  420. pass
  421. print(f"[AI Task] Stream finished. Content length: {len(full_content)}")
  422. if len(full_content) == 0:
  423. print(f"[AI Task] WARNING: No content received from AI stream.")
  424. # Continue to JSON parse to fail gracefully
  425. # Clean JSON
  426. try:
  427. # 1. Try finding [...] array
  428. start = full_content.find('[')
  429. end = full_content.rfind(']')
  430. # 2. If not found, try finding {...} object and wrap it
  431. is_single_object = False
  432. if start == -1 or end == -1 or end <= start:
  433. start = full_content.find('{')
  434. end = full_content.rfind('}')
  435. is_single_object = True
  436. if start != -1 and end != -1 and end > start:
  437. content_clean = full_content[start:end+1]
  438. else:
  439. # Fallback to regex or raw
  440. content_clean = re.sub(r'^```json\s*', '', full_content)
  441. content_clean = re.sub(r'```$', '', content_clean)
  442. parsed = json.loads(content_clean)
  443. # Normalize single object to list
  444. if is_single_object and isinstance(parsed, dict):
  445. parsed = [parsed]
  446. content_clean = json.dumps(parsed, ensure_ascii=False)
  447. elif isinstance(parsed, dict) and not isinstance(parsed, list):
  448. # Just in case json.loads parsed a dict even if we looked for []
  449. parsed = [parsed]
  450. content_clean = json.dumps(parsed, ensure_ascii=False)
  451. # Build spouse name lookup for "female spouse" detection
  452. spouse_name_set = set()
  453. if isinstance(parsed, list):
  454. for person in parsed:
  455. n = normalize_lookup_name(person.get('spouse_name'))
  456. if n:
  457. spouse_name_set.add(n)
  458. # Clean names in parsed content
  459. if isinstance(parsed, list):
  460. for person in parsed:
  461. # Process Name: 'name' is Simplified from AI, 'original_name' is Traditional/Raw from AI
  462. simplified_name = person.get('name', '') or person.get('original_name', '')
  463. original_name = person.get('original_name', '')
  464. # Female spouse: only simplify Chinese, do NOT prepend '留'
  465. if should_skip_liu_prefix_for_person(person, spouse_name_set):
  466. cleaned_simplified = manual_simplify(simplified_name)
  467. else:
  468. # Same-clan default: prepend '留' and handle trailing '公'
  469. cleaned_simplified = clean_name(simplified_name)
  470. person['simplified_name'] = cleaned_simplified
  471. # Store raw name in 'name' field (as requested)
  472. if original_name:
  473. person['name'] = original_name
  474. else:
  475. # Fallback: if no original_name returned, use the uncleaned name as 'name'
  476. # or keep existing logic. But user wants raw in 'name'.
  477. # If AI didn't return original_name, 'name' is likely simplified.
  478. pass # Keep 'name' as is (which is Simplified) if original_name missing
  479. # Father name:同族,需要按“留”姓规则清洗
  480. if 'father_name' in person and person['father_name']:
  481. person['father_name'] = clean_name(person['father_name'])
  482. # Spouse name:只做繁转简,不拼接“留”姓,也不去“公”
  483. if 'spouse_name' in person and person['spouse_name']:
  484. person['spouse_name'] = manual_simplify(person['spouse_name'])
  485. # Re-serialize
  486. content_clean = json.dumps(parsed, ensure_ascii=False)
  487. with conn.cursor() as cursor:
  488. cursor.execute("UPDATE genealogy_records SET ai_status = 2, ai_content = %s WHERE id = %s", (content_clean, record_id))
  489. conn.commit()
  490. print(f"[AI Task] SUCCESS: Record {record_id} processed and saved.")
  491. return # Success
  492. except json.JSONDecodeError as err:
  493. raise Exception(f"JSON Parse Error: {str(err)}. Raw: {full_content}")
  494. else:
  495. raise Exception(f"API Error {response.status_code}: {response.text}")
  496. except Exception as e:
  497. print(f"[AI Task] Attempt {attempt+1} failed for record {record_id}: {e}")
  498. last_exception = e
  499. if attempt < max_retries - 1:
  500. wait_time = 2 * (attempt + 1)
  501. print(f"[AI Task] Waiting {wait_time}s before retry...")
  502. time.sleep(wait_time)
  503. raise last_exception or Exception("Unknown error")
  504. except Exception as e:
  505. print(f"[AI Task] FINAL FAILURE for record {record_id}: {e}")
  506. try:
  507. with conn.cursor() as cursor:
  508. cursor.execute("UPDATE genealogy_records SET ai_status = 3, ai_content = %s WHERE id = %s", (f"Max Retries Exceeded. Error: {str(e)}", record_id))
  509. conn.commit()
  510. except:
  511. pass
  512. finally:
  513. conn.close()
  514. print(f"[AI Task] Task finished for record {record_id}")
  515. def ensure_pdf_table():
  516. conn = get_db_connection()
  517. try:
  518. with conn.cursor() as cursor:
  519. cursor.execute("""
  520. CREATE TABLE IF NOT EXISTS genealogy_pdfs (
  521. id INT AUTO_INCREMENT PRIMARY KEY,
  522. file_name VARCHAR(255) NOT NULL,
  523. oss_url TEXT NOT NULL,
  524. description VARCHAR(500) DEFAULT '',
  525. upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  526. uploader VARCHAR(100) DEFAULT '',
  527. version_name VARCHAR(255) DEFAULT '',
  528. version_source VARCHAR(255) DEFAULT '',
  529. file_provider VARCHAR(100) DEFAULT '',
  530. parse_status INT DEFAULT 0
  531. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  532. """)
  533. # 检查是否存在parse_status字段,如果不存在则添加
  534. cursor.execute("SHOW COLUMNS FROM genealogy_pdfs LIKE 'parse_status'")
  535. if not cursor.fetchone():
  536. cursor.execute("ALTER TABLE genealogy_pdfs ADD COLUMN parse_status INT DEFAULT 0")
  537. # 检查是否存在version_name字段,如果不存在则添加
  538. cursor.execute("SHOW COLUMNS FROM genealogy_pdfs LIKE 'version_name'")
  539. if not cursor.fetchone():
  540. cursor.execute("ALTER TABLE genealogy_pdfs ADD COLUMN version_name VARCHAR(255) DEFAULT ''")
  541. # 检查是否存在version_source字段,如果不存在则添加
  542. cursor.execute("SHOW COLUMNS FROM genealogy_pdfs LIKE 'version_source'")
  543. if not cursor.fetchone():
  544. cursor.execute("ALTER TABLE genealogy_pdfs ADD COLUMN version_source VARCHAR(255) DEFAULT ''")
  545. # 检查是否存在file_provider字段,如果不存在则添加
  546. cursor.execute("SHOW COLUMNS FROM genealogy_pdfs LIKE 'file_provider'")
  547. if not cursor.fetchone():
  548. cursor.execute("ALTER TABLE genealogy_pdfs ADD COLUMN file_provider VARCHAR(100) DEFAULT ''")
  549. conn.commit()
  550. finally:
  551. conn.close()
  552. @app.route('/manager/pdf_management')
  553. def pdf_management():
  554. if 'user_id' not in session:
  555. return redirect(url_for('login'))
  556. username = session.get('username', 'unknown')
  557. is_super_admin = session.get('is_super_admin', 'NOT_SET')
  558. print(f"[PDF Management Access] User: {username}, is_super_admin: {is_super_admin}")
  559. # Verify is_super_admin against database - always check latest status
  560. conn = get_db_connection()
  561. try:
  562. with conn.cursor() as cursor:
  563. cursor.execute("SELECT is_super_admin FROM users WHERE id = %s", (session['user_id'],))
  564. db_result = cursor.fetchone()
  565. db_is_super = db_result['is_super_admin'] if db_result else 0
  566. print(f"[PDF Management Access] DB is_super_admin: {db_is_super}")
  567. if not db_is_super:
  568. print(f"[PDF Management Access] Denied for {username} (DB check)")
  569. flash('无权限访问此页面')
  570. return redirect(url_for('home'))
  571. finally:
  572. conn.close()
  573. print(f"[PDF Management Access] Allowed for {username}")
  574. ensure_pdf_table()
  575. view_id = request.args.get('view', type=int)
  576. preview = request.args.get('preview', type=bool, default=False)
  577. selected_pdf = None
  578. conn = get_db_connection()
  579. try:
  580. with conn.cursor() as cursor:
  581. cursor.execute("SELECT * FROM genealogy_pdfs ORDER BY upload_time DESC")
  582. pdfs = cursor.fetchall()
  583. if view_id and preview:
  584. cursor.execute("SELECT * FROM genealogy_pdfs WHERE id = %s", (view_id,))
  585. selected_pdf = cursor.fetchone()
  586. finally:
  587. conn.close()
  588. return render_template('pdf_management.html', pdfs=pdfs, selected_pdf=selected_pdf)
  589. @app.route('/manager/parse_pdf/<int:pdf_id>', methods=['POST'])
  590. def parse_pdf(pdf_id):
  591. if 'user_id' not in session:
  592. return jsonify({"success": False, "message": "Unauthorized"}), 401
  593. # 标记PDF为解析中
  594. conn = get_db_connection()
  595. try:
  596. with conn.cursor() as cursor:
  597. cursor.execute("UPDATE genealogy_pdfs SET parse_status = 1 WHERE id = %s", (pdf_id,))
  598. conn.commit()
  599. finally:
  600. conn.close()
  601. # 异步执行PDF解析
  602. def parse_pdf_async():
  603. try:
  604. # 获取PDF信息
  605. conn = get_db_connection()
  606. pdf_info = None
  607. try:
  608. with conn.cursor() as cursor:
  609. cursor.execute("SELECT * FROM genealogy_pdfs WHERE id = %s", (pdf_id,))
  610. pdf_info = cursor.fetchone()
  611. finally:
  612. conn.close()
  613. if not pdf_info:
  614. return
  615. # 下载PDF并拆分
  616. pdf_url = pdf_info['oss_url']
  617. response = requests.get(pdf_url)
  618. response.raise_for_status()
  619. # 保存临时PDF文件
  620. temp_pdf_path = f"/tmp/{pdf_info['file_name']}"
  621. with open(temp_pdf_path, 'wb') as f:
  622. f.write(response.content)
  623. # 使用PyMuPDF拆分PDF
  624. doc = fitz.open(temp_pdf_path)
  625. page_count = doc.page_count
  626. # 每个PDF的页码从1开始计算
  627. max_page = 0
  628. # 逐页处理
  629. for i in range(page_count):
  630. page = doc[i]
  631. pix = page.get_pixmap()
  632. image_path = f"/tmp/{pdf_info['file_name']}_page_{i+1}.png"
  633. pix.save(image_path)
  634. # 上传图片到OSS
  635. image_oss_url = upload_to_oss(image_path, f"{pdf_info['file_name']}_page_{i+1}.png")
  636. # 检查上传是否成功
  637. if not image_oss_url:
  638. raise Exception(f"Failed to upload image to OSS: {image_path}")
  639. # 保存到genealogy_records表
  640. conn = get_db_connection()
  641. try:
  642. with conn.cursor() as cursor:
  643. cursor.execute("""
  644. INSERT INTO genealogy_records
  645. (file_name, oss_url, file_type, page_number, genealogy_version, genealogy_source, upload_person, upload_time)
  646. VALUES (%s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
  647. """, (
  648. f"{pdf_info['file_name']}_page_{i+1}.png",
  649. image_oss_url,
  650. '图片',
  651. max_page + i + 1,
  652. pdf_info['version_name'],
  653. pdf_info['version_source'],
  654. pdf_info['file_provider']
  655. ))
  656. conn.commit()
  657. finally:
  658. conn.close()
  659. # 删除临时图片文件
  660. if os.path.exists(image_path):
  661. os.remove(image_path)
  662. # 删除临时PDF文件
  663. if os.path.exists(temp_pdf_path):
  664. os.remove(temp_pdf_path)
  665. # 更新PDF解析状态为成功
  666. conn = get_db_connection()
  667. try:
  668. with conn.cursor() as cursor:
  669. cursor.execute("UPDATE genealogy_pdfs SET parse_status = 2 WHERE id = %s", (pdf_id,))
  670. conn.commit()
  671. finally:
  672. conn.close()
  673. except Exception as e:
  674. # 更新PDF解析状态为失败
  675. conn = get_db_connection()
  676. try:
  677. with conn.cursor() as cursor:
  678. cursor.execute("UPDATE genealogy_pdfs SET parse_status = 3 WHERE id = %s", (pdf_id,))
  679. conn.commit()
  680. finally:
  681. conn.close()
  682. print(f"PDF解析失败: {e}")
  683. # 启动异步任务
  684. thread = threading.Thread(target=parse_pdf_async)
  685. thread.daemon = True
  686. thread.start()
  687. return jsonify({"success": True, "message": "PDF解析已开始,将在后台执行"})
  688. @app.route('/manager/batch_ai_parse', methods=['GET'])
  689. def batch_ai_parse():
  690. """Batch AI parse for unprocessed records."""
  691. if 'user_id' not in session:
  692. return jsonify({"success": False, "message": "Unauthorized"}), 401
  693. # Start background thread
  694. thread = threading.Thread(target=batch_ai_parse_async)
  695. thread.daemon = True
  696. thread.start()
  697. return jsonify({"success": True, "message": "批量AI解析已开始,请稍候查看结果"})
  698. def batch_ai_parse_async():
  699. """Background task to batch AI parse unprocessed records."""
  700. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  701. print(f"[{timestamp}] [Batch AI Parse] Starting batch AI parse task...")
  702. # Get unprocessed records (ai_status = 0)
  703. conn = None
  704. unprocessed_records = []
  705. try:
  706. conn = get_db_connection()
  707. with conn.cursor() as cursor:
  708. cursor.execute("SELECT id, oss_url FROM genealogy_records WHERE ai_status = 0 order by page_number")
  709. unprocessed_records = cursor.fetchall()
  710. conn.close()
  711. conn = None
  712. total_records = len(unprocessed_records)
  713. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  714. print(f"[{timestamp}] [Batch AI Parse] Found {total_records} unprocessed records")
  715. if total_records == 0:
  716. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  717. print(f"[{timestamp}] [Batch AI Parse] No unprocessed records found")
  718. return
  719. # Control concurrency to 5
  720. max_concurrency = 5
  721. semaphore = threading.Semaphore(max_concurrency)
  722. threads = []
  723. def process_record(record):
  724. """Process a single record with semaphore."""
  725. with semaphore:
  726. try:
  727. record_id = record['id']
  728. image_url = record['oss_url']
  729. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  730. print(f"[{timestamp}] [Batch AI Parse] Processing record {record_id}")
  731. process_ai_task(record_id, image_url)
  732. except Exception as e:
  733. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  734. print(f"[{timestamp}] [Batch AI Parse] Error processing record {record['id']}: {e}")
  735. # If failed, we'll handle it in the next batch
  736. # Start threads for each record
  737. for record in unprocessed_records:
  738. thread = threading.Thread(target=process_record, args=(record,))
  739. thread.daemon = True
  740. thread.start()
  741. threads.append(thread)
  742. # Wait for all threads to complete
  743. for thread in threads:
  744. thread.join()
  745. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  746. print(f"[{timestamp}] [Batch AI Parse] Batch processing completed. Processed {total_records} records")
  747. # Check for failed records and restart them
  748. check_failed_records()
  749. except Exception as e:
  750. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  751. print(f"[{timestamp}] [Batch AI Parse] Error: {e}")
  752. finally:
  753. if conn:
  754. try:
  755. conn.close()
  756. except:
  757. pass
  758. def check_failed_records():
  759. """Check for failed records and restart them."""
  760. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  761. print(f"[{timestamp}] [Batch AI Parse] Checking for failed records...")
  762. conn = None
  763. failed_records = []
  764. try:
  765. conn = get_db_connection()
  766. with conn.cursor() as cursor:
  767. cursor.execute("SELECT id, oss_url FROM genealogy_records WHERE ai_status = 3")
  768. failed_records = cursor.fetchall()
  769. conn.close()
  770. conn = None
  771. total_failed = len(failed_records)
  772. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  773. print(f"[{timestamp}] [Batch AI Parse] Found {total_failed} failed records")
  774. if total_failed == 0:
  775. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  776. print(f"[{timestamp}] [Batch AI Parse] No failed records found")
  777. return
  778. # Control concurrency to 5 for failed records
  779. max_concurrency = 5
  780. semaphore = threading.Semaphore(max_concurrency)
  781. threads = []
  782. def process_failed_record(record):
  783. """Process a failed record with semaphore."""
  784. with semaphore:
  785. retry_conn = None
  786. try:
  787. record_id = record['id']
  788. image_url = record['oss_url']
  789. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  790. print(f"[{timestamp}] [Batch AI Parse] Retrying failed record {record_id}")
  791. # Reset status to processing
  792. retry_conn = get_db_connection()
  793. with retry_conn.cursor() as cursor:
  794. cursor.execute("UPDATE genealogy_records SET ai_status = 1 WHERE id = %s", (record_id,))
  795. retry_conn.commit()
  796. retry_conn.close()
  797. retry_conn = None
  798. process_ai_task(record_id, image_url)
  799. except Exception as e:
  800. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  801. print(f"[{timestamp}] [Batch AI Parse] Error retrying record {record['id']}: {e}")
  802. finally:
  803. if retry_conn:
  804. try:
  805. retry_conn.close()
  806. except:
  807. pass
  808. # Start threads for each failed record
  809. for record in failed_records:
  810. thread = threading.Thread(target=process_failed_record, args=(record,))
  811. thread.daemon = True
  812. thread.start()
  813. threads.append(thread)
  814. # Wait for all threads to complete
  815. for thread in threads:
  816. thread.join()
  817. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  818. print(f"[{timestamp}] [Batch AI Parse] Retry processing completed. Retried {total_failed} failed records")
  819. except Exception as e:
  820. timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  821. print(f"[{timestamp}] [Batch AI Parse] Error checking failed records: {e}")
  822. finally:
  823. if conn:
  824. try:
  825. conn.close()
  826. except:
  827. pass
  828. @app.route('/manager/delete_pdf/<int:pdf_id>', methods=['POST'])
  829. def delete_pdf(pdf_id):
  830. if 'user_id' not in session:
  831. return jsonify({"success": False, "message": "Unauthorized"}), 401
  832. conn = get_db_connection()
  833. try:
  834. with conn.cursor() as cursor:
  835. cursor.execute("DELETE FROM genealogy_pdfs WHERE id = %s", (pdf_id,))
  836. conn.commit()
  837. flash('PDF文件记录已删除')
  838. except Exception as e:
  839. flash(f'删除失败: {e}')
  840. finally:
  841. conn.close()
  842. return redirect(url_for('pdf_management'))
  843. @app.route('/manager/')
  844. def index():
  845. if 'user_id' not in session:
  846. return redirect(url_for('login'))
  847. page = request.args.get('page', 1, type=int)
  848. version = request.args.get('version', '').strip()
  849. print(f"Received version parameter: '{version}'")
  850. source = request.args.get('source', '').strip()
  851. person = request.args.get('person', '').strip()
  852. file_type = request.args.get('file_type', '').strip()
  853. per_page = 10
  854. offset = (page - 1) * per_page
  855. conn = get_db_connection()
  856. try:
  857. with conn.cursor() as cursor:
  858. query_conditions = []
  859. params = []
  860. if version:
  861. query_conditions.append("genealogy_version LIKE %s")
  862. params.append(f"%{version}%")
  863. if source:
  864. query_conditions.append("genealogy_source LIKE %s")
  865. params.append(f"%{source}%")
  866. if person:
  867. query_conditions.append("upload_person LIKE %s")
  868. params.append(f"%{person}%")
  869. if file_type:
  870. query_conditions.append("file_type = %s")
  871. params.append(file_type)
  872. where_clause = ""
  873. if query_conditions:
  874. where_clause = "WHERE " + " AND ".join(query_conditions)
  875. count_sql = f"SELECT COUNT(*) as count FROM genealogy_records {where_clause}"
  876. cursor.execute(count_sql, params)
  877. total = cursor.fetchone()['count']
  878. sql = f"SELECT * FROM genealogy_records {where_clause} ORDER BY page_number ASC LIMIT %s OFFSET %s"
  879. cursor.execute(sql, params + [per_page, offset])
  880. records = cursor.fetchall()
  881. total_pages = (total + per_page - 1) // per_page
  882. finally:
  883. conn.close()
  884. 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)
  885. @app.route('/manager/members')
  886. def members():
  887. if 'user_id' not in session:
  888. return redirect(url_for('login'))
  889. search_name = request.args.get('name', '').strip()
  890. page = request.args.get('page', 1, type=int)
  891. per_page = 10
  892. offset = (page - 1) * per_page
  893. print(f"[Members List] Fetching members page: {page}, search: '{search_name}', per_page: {per_page}")
  894. conn = get_db_connection()
  895. try:
  896. with conn.cursor() as cursor:
  897. # 1. Get total count
  898. if search_name:
  899. variants = expand_name_search_variants(search_name)
  900. where_parts = []
  901. params = []
  902. for v in variants:
  903. where_parts.append("(name LIKE %s OR simplified_name LIKE %s)")
  904. like = f"%{v}%"
  905. params.extend([like, like])
  906. where_clause = " OR ".join(where_parts) if where_parts else "name LIKE %s"
  907. if not where_parts:
  908. params = [f"%{search_name}%"]
  909. count_sql = f"SELECT COUNT(*) as count FROM family_member_info WHERE {where_clause}"
  910. print(f"[Members List] Executing count SQL: {count_sql}")
  911. print(f"[Members List] Count SQL parameters: {params}")
  912. cursor.execute(count_sql, tuple(params))
  913. else:
  914. count_sql = "SELECT COUNT(*) as count FROM family_member_info"
  915. print(f"[Members List] Executing count SQL: {count_sql}")
  916. cursor.execute(count_sql)
  917. result = cursor.fetchone()
  918. total = result['count'] if result else 0
  919. total_pages = (total + per_page - 1) // per_page
  920. print(f"[Members List] Total members: {total}, total pages: {total_pages}")
  921. # 2. Get paginated results, ordered by modified_time DESC (or create_time if modified is null/same)
  922. # Using COALESCE to ensure sort works even if modified_time is NULL
  923. order_clause = "ORDER BY COALESCE(modified_time, create_time) DESC"
  924. if search_name:
  925. variants = expand_name_search_variants(search_name)
  926. where_parts = []
  927. params = []
  928. for v in variants:
  929. where_parts.append("(name LIKE %s OR simplified_name LIKE %s)")
  930. like = f"%{v}%"
  931. params.extend([like, like])
  932. where_clause = " OR ".join(where_parts) if where_parts else "(name LIKE %s OR simplified_name LIKE %s)"
  933. if not where_parts:
  934. like = f"%{search_name}%"
  935. params = [like, like]
  936. sql = f"SELECT id, name, simplified_name, sex, name_word_generation, birthday, occupation, family_rank, branch_family_hall, residential_address, is_pass_away, create_time, modified_time FROM family_member_info WHERE {where_clause} {order_clause} LIMIT %s OFFSET %s"
  937. print(f"[Members List] Executing members SQL: {sql}")
  938. print(f"[Members List] Members SQL parameters: {params + [per_page, offset]}")
  939. cursor.execute(sql, tuple(params + [per_page, offset]))
  940. else:
  941. sql = f"SELECT id, name, simplified_name, sex, name_word_generation, birthday, occupation, family_rank, branch_family_hall, residential_address, is_pass_away, create_time, modified_time FROM family_member_info {order_clause} LIMIT %s OFFSET %s"
  942. print(f"[Members List] Executing members SQL: {sql}")
  943. print(f"[Members List] Members SQL parameters: {[per_page, offset]}")
  944. cursor.execute(sql, (per_page, offset))
  945. members = cursor.fetchall()
  946. print(f"[Members List] Fetched {len(members)} members")
  947. # 格式化日期
  948. for m in members:
  949. m['birthday_str'] = format_timestamp(m.get('birthday'))
  950. # 格式化创建时间 (针对 TIMESTAMP 字段)
  951. if m.get('create_time'):
  952. m['create_time_str'] = m['create_time'].strftime('%Y-%m-%d')
  953. if m.get('modified_time'):
  954. m['modified_time_str'] = m['modified_time'].strftime('%Y-%m-%d %H:%M')
  955. finally:
  956. print(f"[Members List] Closing database connection")
  957. conn.close()
  958. return render_template('members.html', members=members, search_name=search_name, page=page, total_pages=total_pages, total=total)
  959. @app.route('/manager/suspected_errors')
  960. def suspected_errors():
  961. if 'user_id' not in session:
  962. return redirect(url_for('login'))
  963. search_name = request.args.get('name', '').strip()
  964. page = request.args.get('page', 1, type=int)
  965. per_page = 20
  966. offset = (page - 1) * per_page
  967. conn = get_db_connection()
  968. try:
  969. with conn.cursor() as cursor:
  970. # Base query with condition for non-empty suspected_error (using TRIM to remove whitespace)
  971. base_query = "SELECT id, name, simplified_name, sex, name_word_generation, birthday, suspected_error FROM family_member_info WHERE suspected_error IS NOT NULL AND TRIM(suspected_error) != ''"
  972. count_query = "SELECT COUNT(*) as count FROM family_member_info WHERE suspected_error IS NOT NULL AND TRIM(suspected_error) != ''"
  973. # Add search condition if provided
  974. params = []
  975. if search_name:
  976. # Support both traditional and simplified name search
  977. base_query += " AND (name LIKE %s OR simplified_name LIKE %s)"
  978. count_query += " AND (name LIKE %s OR simplified_name LIKE %s)"
  979. search_param = f"%{search_name}%"
  980. params.extend([search_param, search_param])
  981. # Get total count
  982. cursor.execute(count_query, params)
  983. result = cursor.fetchone()
  984. total = result['count'] if result else 0
  985. total_pages = (total + per_page - 1) // per_page
  986. # Get members with pagination
  987. base_query += " ORDER BY name LIMIT %s OFFSET %s"
  988. params.extend([per_page, offset])
  989. cursor.execute(base_query, params)
  990. members = cursor.fetchall()
  991. # Format birthday for display
  992. for member in members:
  993. if member['birthday']:
  994. member['birthday_str'] = format_timestamp(member['birthday'])
  995. else:
  996. member['birthday_str'] = '未知'
  997. finally:
  998. conn.close()
  999. return render_template('suspected_errors.html', members=members, search_name=search_name, page=page, total_pages=total_pages, total=total)
  1000. @app.route('/manager/tree')
  1001. def tree():
  1002. if 'user_id' not in session:
  1003. return redirect(url_for('login'))
  1004. return render_template('tree.html')
  1005. @app.route('/manager/lineage_query')
  1006. def lineage_query():
  1007. if 'user_id' not in session:
  1008. return redirect(url_for('login'))
  1009. return render_template('lineage_query.html')
  1010. @app.route('/manager/tree_classic')
  1011. def tree_classic():
  1012. if 'user_id' not in session:
  1013. return redirect(url_for('login'))
  1014. return render_template('tree_classic.html')
  1015. @app.route('/manager/api/tree_data')
  1016. def tree_data():
  1017. if 'user_id' not in session:
  1018. return jsonify({"error": "Unauthorized"}), 401
  1019. conn = get_db_connection()
  1020. try:
  1021. with conn.cursor() as cursor:
  1022. # 获取所有成员
  1023. cursor.execute("SELECT id, name, simplified_name, sex, family_rank, name_word_generation FROM family_member_info")
  1024. members = cursor.fetchall()
  1025. # 获取所有关系 (1:父子 2:母子 10:夫妻 11:兄弟 12:姐妹)
  1026. cursor.execute("SELECT parent_mid, child_mid, relation_type FROM family_relation_info")
  1027. relations = cursor.fetchall()
  1028. return jsonify({"members": members, "relations": relations})
  1029. finally:
  1030. conn.close()
  1031. @app.route('/manager/api/search_member', methods=['POST'])
  1032. def search_member():
  1033. if 'user_id' not in session:
  1034. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1035. data = request.get_json()
  1036. keyword = data.get('keyword', '').strip()
  1037. if not keyword:
  1038. return jsonify({"success": False, "message": "请输入搜索关键词"})
  1039. conn = get_db_connection()
  1040. try:
  1041. with conn.cursor() as cursor:
  1042. cursor.execute("""
  1043. SELECT id, name, simplified_name
  1044. FROM family_member_info
  1045. WHERE name LIKE %s OR simplified_name LIKE %s OR former_name LIKE %s
  1046. ORDER BY
  1047. CASE WHEN name = %s THEN 1
  1048. WHEN simplified_name = %s THEN 2
  1049. WHEN name LIKE %s THEN 3
  1050. WHEN simplified_name LIKE %s THEN 4
  1051. ELSE 5 END
  1052. """, (f'%{keyword}%', f'%{keyword}%', f'%{keyword}%', keyword, keyword, f'{keyword}%', f'{keyword}%'))
  1053. members = cursor.fetchall()
  1054. if members:
  1055. return jsonify({"success": True, "members": members})
  1056. else:
  1057. return jsonify({"success": False, "message": "未找到匹配的成员"})
  1058. finally:
  1059. conn.close()
  1060. @app.route('/manager/api/get_lineage/<int:member_id>')
  1061. def get_lineage(member_id):
  1062. if 'user_id' not in session:
  1063. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1064. import time
  1065. start_time = time.time()
  1066. print(f"[Lineage Query] Starting query for member_id: {member_id} at {time.strftime('%Y-%m-%d %H:%M:%S')}")
  1067. conn = get_db_connection()
  1068. try:
  1069. with conn.cursor() as cursor:
  1070. # Step 1: Get center person
  1071. step_start = time.time()
  1072. cursor.execute("SELECT id, name, simplified_name, name_word, name_word_generation FROM family_member_info WHERE id = %s", (member_id,))
  1073. center = cursor.fetchone()
  1074. print(f"[Lineage Query] Step 1 - Get center: {time.time() - step_start:.3f}s")
  1075. if not center:
  1076. return jsonify({"success": False, "message": "成员不存在"})
  1077. # Step 2: Get ancestors with their siblings (generations)
  1078. step_start = time.time()
  1079. generations = [] # Array of generations, each with main ancestor and siblings
  1080. current_id = member_id
  1081. max_depth = 15
  1082. ancestor_ids = [] # Track ancestor IDs for exclusion when expanding
  1083. displayed_ids = set() # Track IDs that are already displayed
  1084. displayed_ids.add(member_id) # Center person is displayed
  1085. for depth in range(max_depth):
  1086. cursor.execute("""
  1087. SELECT p.id, p.name, p.simplified_name, p.name_word, p.name_word_generation,
  1088. EXISTS(SELECT 1 FROM family_relation_info WHERE parent_mid = p.id AND relation_type IN (1, 2)) as has_children
  1089. FROM family_relation_info r
  1090. JOIN family_member_info p ON r.parent_mid = p.id
  1091. WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
  1092. LIMIT 1
  1093. """, (current_id,))
  1094. parent = cursor.fetchone()
  1095. if not parent:
  1096. break
  1097. ancestor_ids.append(parent['id'])
  1098. displayed_ids.add(parent['id'])
  1099. # Get siblings of this ancestor (father's brothers)
  1100. # First get grandparent (parent's father)
  1101. cursor.execute("""
  1102. SELECT gp.id
  1103. FROM family_relation_info r
  1104. JOIN family_member_info gp ON r.parent_mid = gp.id
  1105. WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
  1106. LIMIT 1
  1107. """, (parent['id'],))
  1108. grandparent = cursor.fetchone()
  1109. parent_siblings = []
  1110. if grandparent:
  1111. # Get siblings of parent (father's brothers)
  1112. cursor.execute("""
  1113. SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
  1114. EXISTS(SELECT 1 FROM family_relation_info WHERE parent_mid = c.id AND relation_type IN (1, 2)) as has_children
  1115. FROM family_relation_info r
  1116. JOIN family_member_info c ON r.child_mid = c.id
  1117. WHERE r.parent_mid = %s AND r.relation_type IN (1, 2) AND c.id != %s
  1118. ORDER BY c.id
  1119. LIMIT 30
  1120. """, (grandparent['id'], parent['id']))
  1121. parent_siblings = cursor.fetchall()
  1122. # Mark sibling IDs as displayed
  1123. for sibling in parent_siblings:
  1124. displayed_ids.add(sibling['id'])
  1125. # Check if parent has any children NOT already displayed
  1126. # Only show expand button if there are undisplayed children
  1127. cursor.execute("""
  1128. SELECT COUNT(*) as count
  1129. FROM family_relation_info r
  1130. JOIN family_member_info c ON r.child_mid = c.id
  1131. WHERE r.parent_mid = %s AND r.relation_type IN (1, 2)
  1132. """, (parent['id'],))
  1133. total_children = cursor.fetchone()['count']
  1134. # Check if current child is displayed (current_id is the child of parent)
  1135. child_displayed = current_id in displayed_ids
  1136. # Show expand if there are children not displayed
  1137. show_expand = total_children > (1 if child_displayed else 0)
  1138. parent['show_expand'] = show_expand
  1139. generations.append({
  1140. 'ancestor': parent,
  1141. 'siblings': parent_siblings,
  1142. 'depth': depth
  1143. })
  1144. current_id = parent['id']
  1145. print(f"[Lineage Query] Step 2 - Get generations ({len(generations)}): {time.time() - step_start:.3f}s")
  1146. # Step 3: Get immediate children only (limited count)
  1147. step_start = time.time()
  1148. cursor.execute("""
  1149. SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
  1150. EXISTS(SELECT 1 FROM family_relation_info WHERE parent_mid = c.id AND relation_type IN (1, 2)) as has_children
  1151. FROM family_relation_info r
  1152. JOIN family_member_info c ON r.child_mid = c.id
  1153. WHERE r.parent_mid = %s AND r.relation_type IN (1, 2)
  1154. ORDER BY c.id
  1155. LIMIT 30
  1156. """, (member_id,))
  1157. children = cursor.fetchall()
  1158. # Initialize children array
  1159. for child in children:
  1160. child['children'] = []
  1161. print(f"[Lineage Query] Step 3 - Get children ({len(children)}): {time.time() - step_start:.3f}s")
  1162. # Step 4: Get siblings of center person
  1163. step_start = time.time()
  1164. siblings = []
  1165. if generations:
  1166. parent_id = generations[0]['ancestor']['id'] # Father
  1167. cursor.execute("""
  1168. SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
  1169. EXISTS(SELECT 1 FROM family_relation_info WHERE parent_mid = c.id AND relation_type IN (1, 2)) as has_children
  1170. FROM family_relation_info r
  1171. JOIN family_member_info c ON r.child_mid = c.id
  1172. WHERE r.parent_mid = %s AND r.relation_type IN (1, 2) AND c.id != %s
  1173. ORDER BY c.id
  1174. LIMIT 30
  1175. """, (parent_id, member_id))
  1176. siblings = cursor.fetchall()
  1177. print(f"[Lineage Query] Step 4 - Get siblings ({len(siblings)}): {time.time() - step_start:.3f}s")
  1178. total_time = time.time() - start_time
  1179. print(f"[Lineage Query] Total time: {total_time:.3f}s")
  1180. return jsonify({
  1181. "success": True,
  1182. "data": {
  1183. "center": center,
  1184. "generations": generations,
  1185. "ancestor_ids": ancestor_ids,
  1186. "siblings": siblings,
  1187. "children": children
  1188. }
  1189. })
  1190. except Exception as e:
  1191. print(f"[Lineage Query] Error: {e}")
  1192. return jsonify({"success": False, "message": str(e)})
  1193. finally:
  1194. conn.close()
  1195. @app.route('/manager/api/get_descendants/<int:parent_id>')
  1196. def get_descendants(parent_id):
  1197. if 'user_id' not in session:
  1198. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1199. # Get excluded IDs from query parameter
  1200. excluded_ids = request.args.get('exclude', '')
  1201. excluded_list = []
  1202. if excluded_ids:
  1203. excluded_list = [int(id.strip()) for id in excluded_ids.split(',') if id.strip().isdigit()]
  1204. print(f"[get_descendants] Parent ID: {parent_id}, Excluded IDs: {excluded_list}")
  1205. conn = get_db_connection()
  1206. try:
  1207. with conn.cursor() as cursor:
  1208. if excluded_list:
  1209. # Build query with exclusion
  1210. placeholders = ', '.join(['%s'] * len(excluded_list))
  1211. cursor.execute(f"""
  1212. SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
  1213. EXISTS(SELECT 1 FROM family_relation_info WHERE parent_mid = c.id AND relation_type IN (1, 2)) as has_children
  1214. FROM family_relation_info r
  1215. JOIN family_member_info c ON r.child_mid = c.id
  1216. WHERE r.parent_mid = %s AND r.relation_type IN (1, 2) AND c.id NOT IN ({placeholders})
  1217. ORDER BY c.id
  1218. LIMIT 20
  1219. """, (parent_id,) + tuple(excluded_list))
  1220. else:
  1221. cursor.execute("""
  1222. SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
  1223. EXISTS(SELECT 1 FROM family_relation_info WHERE parent_mid = c.id AND relation_type IN (1, 2)) as has_children
  1224. FROM family_relation_info r
  1225. JOIN family_member_info c ON r.child_mid = c.id
  1226. WHERE r.parent_mid = %s AND r.relation_type IN (1, 2)
  1227. ORDER BY c.id
  1228. LIMIT 20
  1229. """, (parent_id,))
  1230. children = cursor.fetchall()
  1231. for child in children:
  1232. child['children'] = []
  1233. return jsonify({"success": True, "children": children})
  1234. finally:
  1235. conn.close()
  1236. @app.route('/manager/api/save_relation', methods=['POST'])
  1237. def save_relation():
  1238. if 'user_id' not in session:
  1239. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1240. data = request.json
  1241. source_mid = data.get('source_mid') # The member being dragged
  1242. target_mid = data.get('target_mid') # The member being dropped onto
  1243. rel_type = int(data.get('relation_type'))
  1244. sub_rel_type = int(data.get('sub_relation_type', 0))
  1245. if not source_mid or not target_mid or not rel_type:
  1246. return jsonify({"success": False, "message": "参数不完整"}), 400
  1247. conn = get_db_connection()
  1248. try:
  1249. with conn.cursor() as cursor:
  1250. # 简单处理:如果是父子/母子关系
  1251. # target_mid 是父辈,source_mid 是子辈
  1252. parent_mid = target_mid
  1253. child_mid = source_mid
  1254. gen_diff = 1
  1255. if rel_type == 10: # 夫妻
  1256. # 夫妻关系中,我们通常把关联人设为 parent_mid
  1257. parent_mid = target_mid
  1258. child_mid = source_mid
  1259. gen_diff = 0
  1260. elif rel_type in [11, 12]: # 兄弟姐妹
  1261. # 这里逻辑上比较复杂,通常兄弟姐妹有共同父母。
  1262. # 简化处理:暂时存为同级关系 (gen_diff=0)
  1263. parent_mid = target_mid
  1264. child_mid = source_mid
  1265. gen_diff = 0
  1266. # 删除旧关系
  1267. cursor.execute("DELETE FROM family_relation_info WHERE source_mid = %s", (source_mid,))
  1268. # 插入新关系
  1269. sql = """
  1270. INSERT INTO family_relation_info
  1271. (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff)
  1272. VALUES (%s, %s, %s, %s, %s, %s)
  1273. """
  1274. cursor.execute(sql, (parent_mid, child_mid, rel_type, sub_rel_type, source_mid, gen_diff))
  1275. conn.commit()
  1276. return jsonify({"success": True, "message": "关系已保存"})
  1277. except Exception as e:
  1278. return jsonify({"success": False, "message": str(e)}), 500
  1279. finally:
  1280. conn.close()
  1281. @app.route('/manager/api/members')
  1282. def get_members():
  1283. if 'user_id' not in session:
  1284. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1285. page = int(request.args.get('page', 1))
  1286. search = request.args.get('search', '')
  1287. per_page = 10
  1288. offset = (page - 1) * per_page
  1289. conn = get_db_connection()
  1290. try:
  1291. with conn.cursor() as cursor:
  1292. # Count total members
  1293. if search:
  1294. cursor.execute("SELECT COUNT(*) as total FROM family_member_info WHERE name LIKE %s OR simplified_name LIKE %s",
  1295. (f'%{search}%', f'%{search}%'))
  1296. else:
  1297. cursor.execute("SELECT COUNT(*) as total FROM family_member_info")
  1298. total_result = cursor.fetchone()
  1299. total = total_result['total'] if total_result else 0
  1300. # Get members for current page with father information
  1301. if search:
  1302. cursor.execute("""
  1303. SELECT
  1304. fmi.id, fmi.name, fmi.simplified_name, fmi.sex, fmi.name_word_generation,
  1305. father.name as father_name, father.simplified_name as father_simplified_name, father.name_word_generation as father_generation
  1306. FROM family_member_info fmi
  1307. LEFT JOIN family_relation_info fri ON fmi.id = fri.child_mid AND fri.relation_type IN (1, 2)
  1308. LEFT JOIN family_member_info father ON fri.parent_mid = father.id
  1309. WHERE fmi.name LIKE %s OR fmi.simplified_name LIKE %s
  1310. LIMIT %s OFFSET %s
  1311. """, (f'%{search}%', f'%{search}%', per_page, offset))
  1312. else:
  1313. cursor.execute("""
  1314. SELECT
  1315. fmi.id, fmi.name, fmi.simplified_name, fmi.sex, fmi.name_word_generation,
  1316. father.name as father_name, father.simplified_name as father_simplified_name, father.name_word_generation as father_generation
  1317. FROM family_member_info fmi
  1318. LEFT JOIN family_relation_info fri ON fmi.id = fri.child_mid AND fri.relation_type IN (1, 2)
  1319. LEFT JOIN family_member_info father ON fri.parent_mid = father.id
  1320. LIMIT %s OFFSET %s
  1321. """, (per_page, offset))
  1322. members = cursor.fetchall()
  1323. # Convert to list of dictionaries if needed
  1324. members_list = []
  1325. for member in members:
  1326. members_list.append({
  1327. 'id': member['id'],
  1328. 'name': member['name'],
  1329. 'simplified_name': member['simplified_name'],
  1330. 'sex': member['sex'],
  1331. 'name_word_generation': member.get('name_word_generation'),
  1332. 'father_name': member.get('father_name'),
  1333. 'father_simplified_name': member.get('father_simplified_name'),
  1334. 'father_generation': member.get('father_generation')
  1335. })
  1336. return jsonify({"success": True, "members": members_list, "total": total})
  1337. except Exception as e:
  1338. return jsonify({"success": False, "message": f"获取成员失败: {e}"}), 500
  1339. finally:
  1340. conn.close()
  1341. @app.route('/manager/api/member/<int:member_id>')
  1342. def get_member(member_id):
  1343. if 'user_id' not in session:
  1344. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1345. conn = get_db_connection()
  1346. try:
  1347. with conn.cursor() as cursor:
  1348. cursor.execute("SELECT id, name, name_word_generation, source_record_id FROM family_member_info WHERE id = %s", (member_id,))
  1349. member = cursor.fetchone()
  1350. if not member:
  1351. return jsonify({"success": False, "message": "成员不存在"}), 404
  1352. return jsonify({"member": member})
  1353. except Exception as e:
  1354. return jsonify({"success": False, "message": f"获取成员失败: {e}"}), 500
  1355. finally:
  1356. conn.close()
  1357. @app.route('/manager/api/check_relations', methods=['POST'])
  1358. def check_relations():
  1359. if 'user_id' not in session:
  1360. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1361. data = request.json
  1362. people = data.get('people', [])
  1363. if not people:
  1364. return jsonify({"success": False, "matches": {}})
  1365. conn = get_db_connection()
  1366. matches = {}
  1367. try:
  1368. with conn.cursor() as cursor:
  1369. # Collect all father names and spouse names to query
  1370. names_to_check = set()
  1371. for p in people:
  1372. if p.get('father_name'): names_to_check.add(p['father_name'])
  1373. if p.get('spouse_name'): names_to_check.add(p['spouse_name'])
  1374. if not names_to_check:
  1375. return jsonify({"success": True, "matches": {}})
  1376. # Query DB
  1377. format_strings = ','.join(['%s'] * len(names_to_check))
  1378. if names_to_check:
  1379. 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)
  1380. cursor.execute(sql, tuple(names_to_check) * 2)
  1381. results = cursor.fetchall()
  1382. else:
  1383. results = []
  1384. # Organize by name
  1385. db_map = {} # name -> [list of members]
  1386. for r in results:
  1387. # Add under 'name' (Traditional/Old Simplified)
  1388. if r['name'] not in db_map: db_map[r['name']] = []
  1389. db_map[r['name']].append(r)
  1390. # Add under 'simplified_name' if exists
  1391. if r.get('simplified_name'):
  1392. sname = r['simplified_name']
  1393. if sname not in db_map: db_map[sname] = []
  1394. # Avoid duplicates if simplified_name is same as name?
  1395. # The list might contain same object reference, which is fine.
  1396. if sname != r['name']:
  1397. db_map[sname].append(r)
  1398. # Build matches for each input person
  1399. for index, p in enumerate(people):
  1400. p_match = {}
  1401. # Check Father
  1402. fname = p.get('father_name')
  1403. if fname and fname in db_map:
  1404. candidates = db_map[fname]
  1405. # Filter: Father should be Male usually, and older than child (if birthday available)
  1406. valid_fathers = [c for c in candidates if c['sex'] == 1]
  1407. if valid_fathers:
  1408. p_match['father'] = valid_fathers # Return all candidates
  1409. # Check Spouse
  1410. sname = p.get('spouse_name')
  1411. if sname and sname in db_map:
  1412. candidates = db_map[sname]
  1413. # Filter: Spouse usually opposite sex
  1414. target_sex = 1 if p.get('sex') == '女' else 2
  1415. valid_spouses = [c for c in candidates if c['sex'] == target_sex]
  1416. if valid_spouses:
  1417. p_match['spouse'] = valid_spouses
  1418. if p_match:
  1419. matches[index] = p_match
  1420. return jsonify({"success": True, "matches": matches})
  1421. finally:
  1422. conn.close()
  1423. @app.route('/manager/add_member', methods=['GET', 'POST'])
  1424. def add_member():
  1425. if 'user_id' not in session:
  1426. return redirect(url_for('login'))
  1427. conn = get_db_connection()
  1428. try:
  1429. # Check for source_record_id (from GET or POST)
  1430. source_record_id = request.args.get('record_id') or request.form.get('source_record_id')
  1431. prefilled_content = None
  1432. source_oss_url = None
  1433. if source_record_id:
  1434. with conn.cursor() as cursor:
  1435. cursor.execute("SELECT oss_url, ai_content, ai_status FROM genealogy_records WHERE id = %s", (source_record_id,))
  1436. rec = cursor.fetchone()
  1437. if rec:
  1438. source_oss_url = rec['oss_url']
  1439. # Check ai_status (2 = success)
  1440. if rec['ai_status'] == 2 and rec['ai_content']:
  1441. prefilled_content = rec['ai_content']
  1442. if request.method == 'POST':
  1443. # 处理生日转换为 Unix 时间戳
  1444. birthday_str = request.form.get('birthday')
  1445. birthday_ts = 0
  1446. if birthday_str:
  1447. try:
  1448. birthday_ts = int(datetime.strptime(birthday_str, '%Y-%m-%d').timestamp())
  1449. except ValueError:
  1450. birthday_ts = 0
  1451. # 关系数据
  1452. related_mid = request.form.get('related_mid')
  1453. relation_type = request.form.get('relation_type')
  1454. sub_relation_type = request.form.get('sub_relation_type', 0)
  1455. # 年龄校验逻辑
  1456. if related_mid and relation_type in ['1', '2']: # 1:父子 2:母子
  1457. with conn.cursor() as cursor:
  1458. cursor.execute("SELECT name, birthday FROM family_member_info WHERE id = %s", (related_mid,))
  1459. parent = cursor.fetchone()
  1460. if parent and parent['birthday'] > 0 and birthday_ts > 0:
  1461. if birthday_ts < parent['birthday']:
  1462. error_msg = f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。"
  1463. flash(error_msg)
  1464. # Re-fetch data for rendering
  1465. cursor.execute("SELECT id, name FROM family_member_info ORDER BY name")
  1466. all_members = cursor.fetchall()
  1467. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  1468. images = cursor.fetchall()
  1469. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  1470. return jsonify({
  1471. "success": False,
  1472. "message": error_msg
  1473. }), 400
  1474. selected_member_name = ''
  1475. return render_template('add_member.html', all_members=all_members, images=images,
  1476. prefilled_content=prefilled_content, source_oss_url=source_oss_url, source_record_id=source_record_id, selected_member_name=selected_member_name)
  1477. # 获取表单数据
  1478. data = {
  1479. 'name': request.form['name'],
  1480. 'simplified_name': request.form.get('simplified_name'),
  1481. 'former_name': request.form.get('former_name'),
  1482. 'childhood_name': request.form.get('childhood_name'),
  1483. 'name_word': request.form.get('name_word'),
  1484. 'name_word_generation': ';'.join([g.strip() for g in request.form.getlist('lineage_generations[]') if g.strip()]),
  1485. 'name_title': request.form.get('name_title'),
  1486. 'sex': request.form['sex'],
  1487. 'birthday': birthday_ts,
  1488. 'is_pass_away': request.form.get('is_pass_away', 0),
  1489. 'marital_status': request.form.get('marital_status', 0),
  1490. 'birth_place': request.form.get('birth_place'),
  1491. 'branch_family_hall': request.form.get('branch_family_hall'),
  1492. 'cluster_place': request.form.get('cluster_place'),
  1493. 'nation': request.form.get('nation'),
  1494. 'residential_address': request.form.get('residential_address'),
  1495. 'phone': request.form.get('phone'),
  1496. 'mail': request.form.get('mail'),
  1497. 'wechat_account': request.form.get('wechat_account'),
  1498. 'id_number': request.form.get('id_number'),
  1499. 'occupation': request.form.get('occupation'),
  1500. 'educational': request.form.get('educational'),
  1501. 'blood_type': request.form.get('blood_type'),
  1502. 'religion': request.form.get('religion'),
  1503. 'hobbies': request.form.get('hobbies'),
  1504. 'personal_achievements': request.form.get('personal_achievements'),
  1505. 'family_rank': request.form.get('family_rank'),
  1506. 'tags': request.form.get('tags'),
  1507. 'notes': request.form.get('notes'),
  1508. 'suspected_error': request.form.get('suspected_error').strip() if request.form.get('suspected_error') else '',
  1509. 'source_record_id': request.form.get('source_record_id') or None, # Save source record ID
  1510. 'create_uid': session['user_id'] # 记录当前操作人
  1511. }
  1512. # ... (rest of logic) ...
  1513. with conn.cursor() as cursor:
  1514. print(f"[Add Member] Inserting member data: {data}")
  1515. fields = ", ".join(data.keys())
  1516. placeholders = ", ".join(["%s"] * len(data))
  1517. sql = f"INSERT INTO family_member_info ({fields}) VALUES ({placeholders})"
  1518. print(f"[Add Member] Executing SQL: {sql}")
  1519. print(f"[Add Member] SQL parameters: {list(data.values())}")
  1520. cursor.execute(sql, list(data.values()))
  1521. member_id = cursor.lastrowid
  1522. print(f"[Add Member] Inserted member with ID: {member_id}")
  1523. # 录入关系
  1524. if related_mid and relation_type:
  1525. rel_type = int(relation_type)
  1526. parent_mid = int(related_mid)
  1527. child_mid = member_id
  1528. gen_diff = 1 if rel_type in [1, 2] else 0
  1529. sql_relation = """
  1530. INSERT INTO family_relation_info
  1531. (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff)
  1532. VALUES (%s, %s, %s, %s, %s, %s)
  1533. """
  1534. print(f"[Add Member] Inserting relation: parent_mid={parent_mid}, child_mid={child_mid}, relation_type={rel_type}")
  1535. cursor.execute(sql_relation, (parent_mid, child_mid, rel_type, sub_relation_type, member_id, gen_diff))
  1536. # Update AI Record Status if applicable
  1537. source_record_id = data.get('source_record_id')
  1538. source_index = request.form.get('source_index')
  1539. if source_record_id and source_index and source_index.isdigit():
  1540. try:
  1541. idx = int(source_index)
  1542. print(f"[Add Member] Updating AI record status: record_id={source_record_id}, index={idx}")
  1543. cursor.execute("SELECT ai_content FROM genealogy_records WHERE id = %s FOR UPDATE", (source_record_id,))
  1544. rec = cursor.fetchone()
  1545. if rec and rec['ai_content']:
  1546. import json
  1547. content = json.loads(rec['ai_content'])
  1548. # Ensure content is a list (it might be a dict if single object, though we try to normalize)
  1549. if isinstance(content, dict):
  1550. content = [content]
  1551. if isinstance(content, list):
  1552. updated = False
  1553. if 0 <= idx < len(content):
  1554. # Always update the status regardless of current value
  1555. content[idx]['is_imported'] = True
  1556. content[idx]['imported_member_id'] = member_id
  1557. updated = True
  1558. if updated:
  1559. new_content = json.dumps(content, ensure_ascii=False)
  1560. cursor.execute("UPDATE genealogy_records SET ai_content = %s WHERE id = %s", (new_content, source_record_id))
  1561. print(f"[Add Member] Updated AI record status")
  1562. except Exception as e:
  1563. print(f"[Add Member] Error updating AI content status: {e}")
  1564. print(f"[Add Member] Committing transaction")
  1565. if safe_commit(conn):
  1566. print(f"[Add Member] Transaction committed successfully")
  1567. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  1568. return jsonify({"success": True, "message": "成员录入成功", "member_id": member_id})
  1569. flash('成员录入成功')
  1570. return redirect(url_for('members'))
  1571. else:
  1572. print(f"[Add Member] Transaction commit failed!")
  1573. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  1574. return jsonify({"success": False, "message": "成员录入失败,事务提交失败"}), 500
  1575. flash('成员录入失败,事务提交失败')
  1576. return redirect(url_for('add_member'))
  1577. with conn.cursor() as cursor:
  1578. cursor.execute("SELECT id, name FROM family_member_info ORDER BY name")
  1579. all_members = cursor.fetchall()
  1580. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  1581. images = cursor.fetchall()
  1582. except Exception as e:
  1583. flash(f'发生错误: {e}')
  1584. all_members = []
  1585. images = []
  1586. finally:
  1587. conn.close()
  1588. selected_member_name = ''
  1589. return render_template('add_member.html', all_members=all_members, images=images,
  1590. prefilled_content=prefilled_content, source_oss_url=source_oss_url, source_record_id=source_record_id, selected_member_name=selected_member_name)
  1591. @app.route('/manager/edit_member/<int:member_id>', methods=['GET', 'POST'])
  1592. def edit_member(member_id):
  1593. if 'user_id' not in session:
  1594. return redirect(url_for('login'))
  1595. conn = get_db_connection()
  1596. try:
  1597. if request.method == 'POST':
  1598. birthday_str = request.form.get('birthday')
  1599. birthday_ts = 0
  1600. if birthday_str:
  1601. try:
  1602. birthday_ts = int(datetime.strptime(birthday_str, '%Y-%m-%d').timestamp())
  1603. except ValueError:
  1604. birthday_ts = 0
  1605. # 关系数据
  1606. related_mid = request.form.get('related_mid')
  1607. relation_type = request.form.get('relation_type')
  1608. sub_relation_type = request.form.get('sub_relation_type', 0)
  1609. # 年龄校验逻辑
  1610. if related_mid and relation_type in ['1', '2']:
  1611. with conn.cursor() as cursor:
  1612. cursor.execute("SELECT name, birthday FROM family_member_info WHERE id = %s", (related_mid,))
  1613. parent = cursor.fetchone()
  1614. if parent and parent['birthday'] > 0 and birthday_ts > 0:
  1615. if birthday_ts < parent['birthday']:
  1616. flash(f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。")
  1617. # 重新加载编辑页所需数据
  1618. cursor.execute("SELECT * FROM family_member_info WHERE id = %s", (member_id,))
  1619. member = cursor.fetchone()
  1620. member['birthday_date'] = birthday_str # 保持用户输入
  1621. cursor.execute("SELECT id, name FROM family_member_info WHERE id != %s ORDER BY name", (member_id,))
  1622. all_members = cursor.fetchall()
  1623. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  1624. images = cursor.fetchall()
  1625. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  1626. return jsonify({
  1627. "success": False,
  1628. "message": f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。"
  1629. }), 400
  1630. selected_member_name = ''
  1631. return render_template('add_member.html', member=member, images=images, all_members=all_members, selected_member_name=selected_member_name)
  1632. data = {
  1633. 'name': request.form['name'],
  1634. 'simplified_name': request.form.get('simplified_name'),
  1635. 'former_name': request.form.get('former_name'),
  1636. 'childhood_name': request.form.get('childhood_name'),
  1637. 'name_word': request.form.get('name_word'),
  1638. 'name_word_generation': ';'.join([g.strip() for g in request.form.getlist('lineage_generations[]') if g.strip()]),
  1639. 'name_title': request.form.get('name_title'),
  1640. 'sex': request.form['sex'],
  1641. 'birthday': birthday_ts,
  1642. 'is_pass_away': request.form.get('is_pass_away', 0),
  1643. 'marital_status': request.form.get('marital_status', 0),
  1644. 'birth_place': request.form.get('birth_place'),
  1645. 'branch_family_hall': request.form.get('branch_family_hall'),
  1646. 'cluster_place': request.form.get('cluster_place'),
  1647. 'nation': request.form.get('nation'),
  1648. 'residential_address': request.form.get('residential_address'),
  1649. 'phone': request.form.get('phone'),
  1650. 'mail': request.form.get('mail'),
  1651. 'wechat_account': request.form.get('wechat_account'),
  1652. 'id_number': request.form.get('id_number'),
  1653. 'occupation': request.form.get('occupation'),
  1654. 'educational': request.form.get('educational'),
  1655. 'blood_type': request.form.get('blood_type'),
  1656. 'religion': request.form.get('religion'),
  1657. 'hobbies': request.form.get('hobbies'),
  1658. 'personal_achievements': request.form.get('personal_achievements'),
  1659. 'family_rank': request.form.get('family_rank'),
  1660. 'tags': request.form.get('tags'),
  1661. 'notes': request.form.get('notes'),
  1662. 'suspected_error': request.form.get('suspected_error').strip() if request.form.get('suspected_error') else '',
  1663. 'source_record_id': request.form.get('source_record_id') or None,
  1664. 'create_uid': session['user_id'] # 记录当前操作人
  1665. }
  1666. # 关系数据
  1667. related_mid = request.form.get('related_mid')
  1668. relation_type = request.form.get('relation_type')
  1669. sub_relation_type = request.form.get('sub_relation_type', 0)
  1670. with conn.cursor() as cursor:
  1671. print(f"[Edit Member] Updating member data: {data}")
  1672. update_parts = [f"{k} = %s" for k in data.keys()]
  1673. sql = f"UPDATE family_member_info SET {', '.join(update_parts)} WHERE id = %s"
  1674. print(f"[Edit Member] Executing SQL: {sql}")
  1675. print(f"[Edit Member] SQL parameters: {list(data.values()) + [member_id]}")
  1676. cursor.execute(sql, list(data.values()) + [member_id])
  1677. print(f"[Edit Member] Updated member with ID: {member_id}")
  1678. # 更新关系
  1679. if related_mid and relation_type:
  1680. rel_type = int(relation_type)
  1681. print(f"[Edit Member] Deleting existing relations for member ID: {member_id}")
  1682. cursor.execute("DELETE FROM family_relation_info WHERE source_mid = %s", (member_id,))
  1683. parent_mid = int(related_mid)
  1684. child_mid = member_id
  1685. gen_diff = 1 if rel_type in [1, 2] else 0
  1686. sql_relation = """
  1687. INSERT INTO family_relation_info
  1688. (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff)
  1689. VALUES (%s, %s, %s, %s, %s, %s)
  1690. """
  1691. print(f"[Edit Member] Inserting relation: parent_mid={parent_mid}, child_mid={child_mid}, relation_type={rel_type}")
  1692. cursor.execute(sql_relation, (parent_mid, child_mid, rel_type, sub_relation_type, member_id, gen_diff))
  1693. # Update AI Record Status if applicable
  1694. source_record_id = data.get('source_record_id')
  1695. source_index = request.form.get('source_index')
  1696. if source_record_id and source_index and source_index.isdigit():
  1697. try:
  1698. idx = int(source_index)
  1699. print(f"[Edit Member] Updating AI record status: record_id={source_record_id}, index={idx}")
  1700. cursor.execute("SELECT ai_content FROM genealogy_records WHERE id = %s FOR UPDATE", (source_record_id,))
  1701. rec = cursor.fetchone()
  1702. if rec and rec['ai_content']:
  1703. import json
  1704. content = json.loads(rec['ai_content'])
  1705. if isinstance(content, dict):
  1706. content = [content]
  1707. if isinstance(content, list):
  1708. updated = False
  1709. if 0 <= idx < len(content):
  1710. # Always update the status regardless of current value
  1711. content[idx]['is_imported'] = True
  1712. content[idx]['imported_member_id'] = member_id
  1713. updated = True
  1714. if updated:
  1715. new_content = json.dumps(content, ensure_ascii=False)
  1716. cursor.execute("UPDATE genealogy_records SET ai_content = %s WHERE id = %s", (new_content, source_record_id))
  1717. print(f"[Edit Member] Updated AI record status")
  1718. except Exception as e:
  1719. print(f"[Edit Member] Error updating AI content status: {e}")
  1720. print(f"[Edit Member] Committing transaction")
  1721. conn.commit()
  1722. print(f"[Edit Member] Transaction committed successfully")
  1723. if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
  1724. return jsonify({"success": True, "message": "成员信息更新成功"})
  1725. flash('成员信息更新成功')
  1726. return redirect(url_for('members'))
  1727. with conn.cursor() as cursor:
  1728. cursor.execute("SELECT * FROM family_member_info WHERE id = %s", (member_id,))
  1729. member = cursor.fetchone()
  1730. if not member:
  1731. flash('成员不存在')
  1732. return redirect(url_for('members'))
  1733. # 格式化日期供显示
  1734. if member.get('birthday'):
  1735. member['birthday_date'] = format_timestamp(member['birthday'])
  1736. # 获取现有关系
  1737. cursor.execute("SELECT * FROM family_relation_info WHERE source_mid = %s LIMIT 1", (member_id,))
  1738. current_relation = cursor.fetchone()
  1739. cursor.execute("SELECT id, name FROM family_member_info WHERE id != %s ORDER BY name", (member_id,))
  1740. all_members = cursor.fetchall()
  1741. cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
  1742. images = cursor.fetchall()
  1743. finally:
  1744. conn.close()
  1745. # Calculate selected_member_name based on current_relation
  1746. selected_member_name = ''
  1747. if current_relation and current_relation['parent_mid']:
  1748. for m in all_members:
  1749. if m['id'] == current_relation['parent_mid']:
  1750. selected_member_name = m['name']
  1751. break
  1752. # Get source_record_id from member data
  1753. source_record_id = member.get('source_record_id') if member else None
  1754. return render_template('add_member.html', member=member, images=images, all_members=all_members, current_relation=current_relation, selected_member_name=selected_member_name, source_record_id=source_record_id)
  1755. @app.route('/manager/member_detail/<int:member_id>')
  1756. def member_detail(member_id):
  1757. if 'user_id' not in session:
  1758. return redirect(url_for('login'))
  1759. conn = get_db_connection()
  1760. try:
  1761. with conn.cursor() as cursor:
  1762. # Join with genealogy_records to get source image info
  1763. sql = """
  1764. SELECT m.*, r.oss_url as source_image_url, r.page_number as source_page,
  1765. r.genealogy_version, r.genealogy_source, r.upload_person
  1766. FROM family_member_info m
  1767. LEFT JOIN genealogy_records r ON m.source_record_id = r.id
  1768. WHERE m.id = %s
  1769. """
  1770. cursor.execute(sql, (member_id,))
  1771. member = cursor.fetchone()
  1772. if not member:
  1773. flash('成员不存在')
  1774. return redirect(url_for('members'))
  1775. member['birthday_str'] = format_timestamp(member.get('birthday'))
  1776. # 获取关系
  1777. cursor.execute("""
  1778. SELECT m.id, m.name, r.relation_type
  1779. FROM family_relation_info r
  1780. JOIN family_member_info m ON r.parent_mid = m.id
  1781. WHERE r.child_mid = %s
  1782. """, (member_id,))
  1783. parents = cursor.fetchall()
  1784. cursor.execute("""
  1785. SELECT m.id, m.name, r.relation_type
  1786. FROM family_relation_info r
  1787. JOIN family_member_info m ON r.child_mid = m.id
  1788. WHERE r.parent_mid = %s
  1789. """, (member_id,))
  1790. children = cursor.fetchall()
  1791. finally:
  1792. conn.close()
  1793. return render_template('member_detail.html', member=member, parents=parents, children=children)
  1794. @app.route('/manager/delete_member/<int:member_id>', methods=['POST'])
  1795. def delete_member(member_id):
  1796. if 'user_id' not in session:
  1797. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1798. conn = get_db_connection()
  1799. try:
  1800. with conn.cursor() as cursor:
  1801. # 1. 删除关系表中关联该成员的所有记录
  1802. cursor.execute("DELETE FROM family_relation_info WHERE parent_mid = %s OR child_mid = %s OR source_mid = %s",
  1803. (member_id, member_id, member_id))
  1804. # 2. 删除成员本身
  1805. cursor.execute("DELETE FROM family_member_info WHERE id = %s", (member_id,))
  1806. conn.commit()
  1807. flash('成员及其关系已成功删除')
  1808. return redirect(url_for('members'))
  1809. except Exception as e:
  1810. conn.rollback()
  1811. flash(f'删除失败: {e}')
  1812. return redirect(url_for('members'))
  1813. finally:
  1814. conn.close()
  1815. @app.route('/manager/home')
  1816. def home():
  1817. """Home page - Dashboard for the genealogy management system"""
  1818. if 'user_id' not in session:
  1819. return redirect(url_for('login'))
  1820. # Force re-login if is_super_admin not set in session (fresh login required)
  1821. if 'is_super_admin' not in session:
  1822. session.clear()
  1823. flash('请重新登录以获取最新权限')
  1824. return redirect(url_for('login'))
  1825. conn = get_db_connection()
  1826. try:
  1827. with conn.cursor() as cursor:
  1828. # Get member count
  1829. cursor.execute("SELECT COUNT(*) as count FROM family_member_info")
  1830. member_count = cursor.fetchone()['count']
  1831. # Get record count
  1832. cursor.execute("SELECT COUNT(*) as count FROM genealogy_records")
  1833. record_count = cursor.fetchone()['count']
  1834. # Get PDF count
  1835. cursor.execute("SELECT COUNT(*) as count FROM genealogy_pdfs")
  1836. pdf_count = cursor.fetchone()['count']
  1837. # Get suspected error count
  1838. cursor.execute("SELECT COUNT(*) as count FROM family_member_info WHERE suspected_error IS NOT NULL AND TRIM(suspected_error) != ''")
  1839. error_count = cursor.fetchone()['count']
  1840. finally:
  1841. conn.close()
  1842. return render_template('home.html',
  1843. member_count=member_count,
  1844. record_count=record_count,
  1845. pdf_count=pdf_count,
  1846. error_count=error_count)
  1847. @app.route('/manager/login', methods=['GET', 'POST'])
  1848. def login():
  1849. if request.method == 'POST':
  1850. username = request.form['username']
  1851. password = request.form['password']
  1852. try:
  1853. conn = get_db_connection()
  1854. try:
  1855. with conn.cursor() as cursor:
  1856. cursor.execute("SELECT * FROM users WHERE username=%s AND password=%s", (username, password))
  1857. user = cursor.fetchone()
  1858. if user:
  1859. session['user_id'] = user['id']
  1860. session['username'] = user['username']
  1861. session['is_super_admin'] = user.get('is_super_admin', 0) == 1
  1862. return redirect(url_for('home'))
  1863. else:
  1864. flash('用户名或密码错误')
  1865. finally:
  1866. conn.close()
  1867. except Exception as e:
  1868. flash(f'数据库连接错误: {str(e)}')
  1869. print(f'Login error: {str(e)}')
  1870. return render_template('login.html')
  1871. @app.route('/manager/logout')
  1872. def logout():
  1873. session.clear()
  1874. return redirect(url_for('login'))
  1875. @app.route('/manager/api/check_name')
  1876. def check_name():
  1877. if 'user_id' not in session:
  1878. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1879. name = request.args.get('name', '').strip()
  1880. if not name:
  1881. return jsonify({"success": True, "exists": False})
  1882. conn = get_db_connection()
  1883. try:
  1884. with conn.cursor() as cursor:
  1885. # Check for name or simplified_name match
  1886. 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))
  1887. matches = cursor.fetchall()
  1888. if matches:
  1889. # Format birthday for display
  1890. for m in matches:
  1891. if m.get('birthday'):
  1892. m['birthday_str'] = format_timestamp(m['birthday'])
  1893. else:
  1894. m['birthday_str'] = '未知'
  1895. return jsonify({"success": True, "exists": True, "matches": matches})
  1896. else:
  1897. return jsonify({"success": True, "exists": False})
  1898. except Exception as e:
  1899. return jsonify({"success": False, "error": str(e)}), 500
  1900. finally:
  1901. conn.close()
  1902. import requests
  1903. import json
  1904. import re
  1905. @app.route('/manager/api/recognize_image', methods=['POST'])
  1906. def recognize_image():
  1907. if 'user_id' not in session:
  1908. return jsonify({"success": False, "message": "Unauthorized"}), 401
  1909. data = request.json
  1910. image_url = data.get('image_url')
  1911. if not image_url:
  1912. return jsonify({"success": False, "message": "No image URL provided"}), 400
  1913. api_key = "a1800657-9212-4afe-9b7c-b49f015c54d3"
  1914. api_url = "https://ark.cn-beijing.volces.com/api/v3/responses"
  1915. prompt = """
  1916. 请分析这张家谱图片,提取其中关于人物的信息。
  1917. 请务必将繁体字转换为简体字(original_name 字段除外)。
  1918. 特别注意:'name' 字段必须是纯简体中文,不能包含繁体字(例如:'學'应转换为'学','劉'应转换为'刘','萬'应转换为'万')。
  1919. 请提取以下字段(如果存在):
  1920. - original_name: 原始姓名(严格保持图片上的繁体字,不做任何修改或转换)
  1921. - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
  1922. - sex: 性别(男/女)
  1923. - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
  1924. - death_date: 逝世日期(如文本中出现“殁”、“葬”、“卒”等字眼及其对应的时间,请提取)
  1925. - father_name: 父亲姓名
  1926. - spouse_name: 配偶姓名
  1927. - generation: 第几世/代数
  1928. - name_word: 字辈(例如名字为“学勤公”,“学”为字辈;提取名字中的字辈信息)
  1929. - education: 学历/功名
  1930. - title: 官职/称号
  1931. 请严格以JSON列表格式返回,不要包含Markdown代码块标记(如 ```json ... ```),直接返回JSON数组。
  1932. 如果包含多个人物,请都提取出来。
  1933. """
  1934. ai_payload_url = get_normalized_base64_image(image_url)
  1935. payload = {
  1936. "model": "doubao-seed-1-8-251228",
  1937. "stream": True,
  1938. "input": [
  1939. {
  1940. "role": "user",
  1941. "content": [
  1942. {
  1943. "type": "input_image",
  1944. "image_url": ai_payload_url
  1945. },
  1946. {
  1947. "type": "input_text",
  1948. "text": prompt
  1949. }
  1950. ]
  1951. }
  1952. ]
  1953. }
  1954. headers = {
  1955. "Authorization": f"Bearer {api_key}",
  1956. "Content-Type": "application/json"
  1957. }
  1958. def generate():
  1959. yield "正在连接 AI 服务...\n"
  1960. try:
  1961. # 使用 stream=True, timeout=120
  1962. # 增加 verify=False 以防 SSL 问题(开发环境)
  1963. # 增加 proxies=None 以防本地代理干扰
  1964. with requests.post(
  1965. api_url,
  1966. json=payload,
  1967. headers=headers,
  1968. stream=True,
  1969. timeout=1200,
  1970. verify=False,
  1971. proxies={"http": None, "https": None}
  1972. ) as r:
  1973. if r.status_code != 200:
  1974. yield f"Error: API returned status code {r.status_code}. Response: {r.text}"
  1975. return
  1976. yield "连接成功,正在等待 AI 响应...\n"
  1977. full_reasoning = ""
  1978. json_started = False
  1979. for line in r.iter_lines():
  1980. if line:
  1981. line_str = line.decode('utf-8')
  1982. if line_str.startswith('data: '):
  1983. json_str = line_str[6:]
  1984. if json_str.strip() == '[DONE]':
  1985. break
  1986. try:
  1987. chunk = json.loads(json_str)
  1988. # 处理 standard OpenAI choices format (content)
  1989. if 'choices' in chunk and len(chunk['choices']) > 0:
  1990. delta = chunk['choices'][0].get('delta', {})
  1991. if 'content' in delta:
  1992. if not json_started:
  1993. yield "|||JSON_START|||"
  1994. json_started = True
  1995. yield delta['content']
  1996. # 处理 standard OpenAI choices format (reasoning_content) if any
  1997. if 'reasoning_content' in delta:
  1998. yield f"\n[推理]: {delta['reasoning_content']}"
  1999. # 处理 Doubao/Volcano specific formats
  2000. # Type: response.reasoning_summary_text.delta
  2001. if chunk.get('type') == 'response.reasoning_summary_text.delta':
  2002. if 'delta' in chunk:
  2003. yield chunk['delta']
  2004. # Type: response.text.delta
  2005. if chunk.get('type') == 'response.text.delta':
  2006. if 'delta' in chunk:
  2007. if not json_started:
  2008. yield "|||JSON_START|||"
  2009. json_started = True
  2010. yield chunk['delta']
  2011. # Type: response.output_item.added (May contain initial content or status)
  2012. # Type: response.reasoning_summary_part.added
  2013. except Exception as e:
  2014. print(f"Chunk parse error: {e}")
  2015. else:
  2016. # 尝试直接解析非 data: 开头的行
  2017. try:
  2018. chunk = json.loads(line_str)
  2019. if 'choices' in chunk and len(chunk['choices']) > 0:
  2020. content = chunk['choices'][0]['message']['content']
  2021. yield content
  2022. except:
  2023. pass
  2024. except Exception as e:
  2025. yield f"\n[Error: {str(e)}]"
  2026. return Response(stream_with_context(generate()), mimetype='text/plain')
  2027. @app.route('/manager/api/start_analysis/<int:record_id>', methods=['POST'])
  2028. def start_analysis(record_id):
  2029. if 'user_id' not in session:
  2030. return jsonify({"success": False, "message": "Unauthorized"}), 401
  2031. conn = get_db_connection()
  2032. try:
  2033. with conn.cursor() as cursor:
  2034. # Check if record exists
  2035. cursor.execute("SELECT oss_url, ai_status FROM genealogy_records WHERE id = %s", (record_id,))
  2036. record = cursor.fetchone()
  2037. if not record:
  2038. return jsonify({"success": False, "message": "Record not found"}), 404
  2039. # Update status to processing (1)
  2040. cursor.execute("UPDATE genealogy_records SET ai_status = 1 WHERE id = %s", (record_id,))
  2041. conn.commit()
  2042. # Start background task
  2043. threading.Thread(target=process_ai_task, args=(record_id, record['oss_url'])).start()
  2044. return jsonify({"success": True, "message": "Analysis started"})
  2045. except Exception as e:
  2046. return jsonify({"success": False, "message": str(e)}), 500
  2047. finally:
  2048. conn.close()
  2049. def process_files_background(upload_folder, saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person):
  2050. current_suggested_page = int(manual_page) if manual_page and str(manual_page).isdigit() else suggested_page
  2051. ensure_pdf_table()
  2052. for item in saved_files:
  2053. if len(item) >= 4:
  2054. filename, file_path, file_page, original_filename = item[0], item[1], item[2], item[3]
  2055. elif len(item) == 3:
  2056. filename, file_path, file_page = item
  2057. original_filename = filename
  2058. else:
  2059. filename, file_path = item[0], item[1]
  2060. file_page = None
  2061. original_filename = filename
  2062. try:
  2063. if filename.lower().endswith('.pdf'):
  2064. import uuid
  2065. display_pdf_name = (original_filename or filename).strip() or filename
  2066. oss_pdf_name = secure_filename(display_pdf_name)
  2067. if not oss_pdf_name or not oss_pdf_name.lower().endswith('.pdf'):
  2068. oss_pdf_name = f"genealogy_pdf_{uuid.uuid4().hex[:8]}.pdf"
  2069. pdf_oss_url = upload_to_oss(file_path, custom_filename=oss_pdf_name)
  2070. if pdf_oss_url:
  2071. desc_parts = []
  2072. if genealogy_version:
  2073. desc_parts.append(genealogy_version)
  2074. if genealogy_source:
  2075. desc_parts.append(genealogy_source)
  2076. pdf_description = ' · '.join(desc_parts) if desc_parts else ''
  2077. conn_pdf = get_db_connection()
  2078. try:
  2079. with conn_pdf.cursor() as cursor:
  2080. cursor.execute(
  2081. "INSERT INTO genealogy_pdfs (file_name, oss_url, description, uploader) VALUES (%s, %s, %s, %s)",
  2082. (display_pdf_name, pdf_oss_url, pdf_description, upload_person or '')
  2083. )
  2084. conn_pdf.commit()
  2085. except Exception as pdf_meta_e:
  2086. print(f"Error inserting genealogy_pdfs for {display_pdf_name}: {pdf_meta_e}")
  2087. finally:
  2088. conn_pdf.close()
  2089. else:
  2090. print(f"Warning: full PDF upload to OSS failed for {filename}, scan pages will still be processed.")
  2091. doc = fitz.open(file_path)
  2092. for page_index in range(len(doc)):
  2093. img_path = None
  2094. try:
  2095. page = doc.load_page(page_index)
  2096. max_dim = max(page.rect.width, page.rect.height)
  2097. zoom = 2000 / max_dim if max_dim > 0 else 2.0
  2098. if zoom > 2.5: zoom = 2.5
  2099. mat = fitz.Matrix(zoom, zoom)
  2100. # Use get_pixmap with matrix directly
  2101. pix = page.get_pixmap(matrix=mat)
  2102. final_page = current_suggested_page
  2103. if genealogy_version and genealogy_source:
  2104. if final_page is not None and str(final_page).strip() != '':
  2105. img_filename = f"{genealogy_version}_{genealogy_source}_{final_page}.jpg"
  2106. else:
  2107. img_filename = f"{genealogy_version}_{genealogy_source}.jpg"
  2108. else:
  2109. img_filename = f"{os.path.splitext(filename)[0]}_page_{page_index+1}.jpg"
  2110. img_path = os.path.join(upload_folder, img_filename)
  2111. # Save the pixmap to the image path
  2112. pix.save(img_path)
  2113. oss_url = upload_to_oss(img_path, custom_filename=img_filename)
  2114. if oss_url:
  2115. conn = get_db_connection()
  2116. try:
  2117. with conn.cursor() as cursor:
  2118. sql = """INSERT INTO genealogy_records
  2119. (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type)
  2120. VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
  2121. cursor.execute(sql, (img_filename, oss_url, final_page, genealogy_version, genealogy_source, upload_person, 'PDF'))
  2122. record_id = cursor.lastrowid
  2123. conn.commit()
  2124. threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
  2125. current_suggested_page += 1
  2126. finally:
  2127. conn.close()
  2128. except Exception as page_e:
  2129. print(f"Error processing page {page_index} of {filename}: {page_e}")
  2130. finally:
  2131. if img_path and os.path.exists(img_path):
  2132. try:
  2133. os.remove(img_path)
  2134. except:
  2135. pass
  2136. doc.close()
  2137. else:
  2138. img_path = compress_image_if_needed(file_path)
  2139. # Use explicitly set page number if provided, otherwise extract from filename or auto-increment
  2140. if file_page and str(file_page).isdigit():
  2141. final_page = int(file_page)
  2142. current_suggested_page = final_page + 1
  2143. page_num = final_page
  2144. else:
  2145. page_num = extract_page_number(img_path)
  2146. final_page = page_num if page_num else current_suggested_page
  2147. ext = os.path.splitext(img_path)[1]
  2148. if genealogy_version and genealogy_source:
  2149. if final_page is not None and str(final_page).strip() != '':
  2150. img_filename = f"{genealogy_version}_{genealogy_source}_{final_page}{ext}"
  2151. else:
  2152. img_filename = f"{genealogy_version}_{genealogy_source}{ext}"
  2153. else:
  2154. img_filename = os.path.basename(img_path)
  2155. oss_url = upload_to_oss(img_path, custom_filename=img_filename)
  2156. if oss_url:
  2157. conn = get_db_connection()
  2158. try:
  2159. with conn.cursor() as cursor:
  2160. sql = """INSERT INTO genealogy_records
  2161. (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type)
  2162. VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
  2163. cursor.execute(sql, (img_filename, oss_url, final_page, genealogy_version, genealogy_source, upload_person, '图片'))
  2164. record_id = cursor.lastrowid
  2165. conn.commit()
  2166. threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
  2167. if page_num:
  2168. current_suggested_page = page_num + 1
  2169. else:
  2170. current_suggested_page += 1
  2171. finally:
  2172. conn.close()
  2173. if img_path and img_path != file_path and os.path.exists(img_path):
  2174. try:
  2175. os.remove(img_path)
  2176. except:
  2177. pass
  2178. except Exception as e:
  2179. print(f"Error processing file {filename}: {e}")
  2180. finally:
  2181. if os.path.exists(file_path):
  2182. try:
  2183. os.remove(file_path)
  2184. except:
  2185. pass
  2186. @app.route('/manager/upload', methods=['GET', 'POST'])
  2187. def upload():
  2188. if 'user_id' not in session:
  2189. return redirect(url_for('login'))
  2190. # 获取建议页码 (当前最大页码 + 1)
  2191. conn = get_db_connection()
  2192. suggested_page = 1
  2193. try:
  2194. with conn.cursor() as cursor:
  2195. cursor.execute("SELECT MAX(page_number) as max_p FROM genealogy_records")
  2196. result = cursor.fetchone()
  2197. if result and result['max_p']:
  2198. suggested_page = result['max_p'] + 1
  2199. finally:
  2200. conn.close()
  2201. if request.method == 'POST':
  2202. if 'file' not in request.files:
  2203. flash('未选择文件')
  2204. return redirect(request.url)
  2205. files = request.files.getlist('file')
  2206. if not files or files[0].filename == '':
  2207. flash('未选择文件')
  2208. return redirect(request.url)
  2209. manual_page = request.form.get('manual_page')
  2210. genealogy_version = request.form.get('genealogy_version', '')
  2211. genealogy_source = request.form.get('genealogy_source', '')
  2212. upload_person = request.form.get('upload_person', '')
  2213. if not upload_person:
  2214. upload_person = session.get('username', '')
  2215. import uuid
  2216. saved_files = []
  2217. for i, file in enumerate(files):
  2218. if not file or not file.filename:
  2219. continue
  2220. original_filename = file.filename
  2221. ext = os.path.splitext(original_filename)[1].lower()
  2222. base_name = secure_filename(original_filename)
  2223. # If secure_filename removes all characters (e.g., pure Chinese name) or just leaves 'pdf'
  2224. if not base_name or base_name == ext.strip('.'):
  2225. filename = f"upload_{uuid.uuid4().hex[:8]}{ext}"
  2226. else:
  2227. # Ensure the extension is preserved
  2228. if not base_name.lower().endswith(ext):
  2229. filename = f"{base_name}{ext}"
  2230. else:
  2231. filename = base_name
  2232. file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
  2233. file.save(file_path)
  2234. # Fetch individual page number if it exists
  2235. file_page = request.form.get(f'page_number_{i}')
  2236. saved_files.append((filename, file_path, file_page, original_filename))
  2237. if saved_files:
  2238. threading.Thread(
  2239. target=process_files_background,
  2240. args=(app.config['UPLOAD_FOLDER'], saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person)
  2241. ).start()
  2242. flash('上传完成,AI解析中,稍后查看')
  2243. time.sleep(1.5)
  2244. return redirect(url_for('index'))
  2245. return render_template('upload.html', suggested_page=suggested_page)
  2246. @app.route('/manager/save_upload', methods=['POST'])
  2247. def save_upload():
  2248. if 'user_id' not in session: return redirect(url_for('login'))
  2249. filename = request.form.get('filename')
  2250. oss_url = request.form.get('oss_url')
  2251. page_number = request.form.get('page_number')
  2252. genealogy_version = request.form.get('genealogy_version', '')
  2253. genealogy_source = request.form.get('genealogy_source', '')
  2254. upload_person = request.form.get('upload_person', session.get('username', ''))
  2255. file_type = request.form.get('file_type', '图片')
  2256. if not oss_url or not page_number:
  2257. flash('页码不能为空')
  2258. return redirect(url_for('upload'))
  2259. conn = get_db_connection()
  2260. try:
  2261. with conn.cursor() as cursor:
  2262. sql = """INSERT INTO genealogy_records
  2263. (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type)
  2264. VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
  2265. cursor.execute(sql, (filename, oss_url, page_number, genealogy_version, genealogy_source, upload_person, file_type))
  2266. record_id = cursor.lastrowid
  2267. conn.commit()
  2268. # Start AI Task
  2269. threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
  2270. flash('上传完成,AI解析中,稍后查看')
  2271. except Exception as e:
  2272. flash(f'保存失败: {e}')
  2273. finally:
  2274. conn.close()
  2275. return redirect(url_for('index'))
  2276. @app.route('/manager/delete_upload/<int:record_id>', methods=['POST'])
  2277. def delete_upload(record_id):
  2278. if 'user_id' not in session:
  2279. return jsonify({"success": False, "message": "Unauthorized"}), 401
  2280. conn = get_db_connection()
  2281. try:
  2282. with conn.cursor() as cursor:
  2283. # 删除记录
  2284. cursor.execute("DELETE FROM genealogy_records WHERE id = %s", (record_id,))
  2285. conn.commit()
  2286. flash('文件记录已成功删除')
  2287. return redirect(url_for('index'))
  2288. except Exception as e:
  2289. conn.rollback()
  2290. flash(f'删除失败: {e}')
  2291. return redirect(url_for('index'))
  2292. finally:
  2293. conn.close()
  2294. @app.route('/manager/upload_pdf', methods=['GET', 'POST'])
  2295. def upload_pdf():
  2296. if 'user_id' not in session:
  2297. return redirect(url_for('login'))
  2298. if request.method == 'GET':
  2299. return render_template('upload_pdf.html')
  2300. # POST请求处理
  2301. if 'file' not in request.files:
  2302. flash('请选择要上传的PDF文件')
  2303. return redirect(request.url)
  2304. file = request.files['file']
  2305. if file.filename == '':
  2306. flash('请选择要上传的PDF文件')
  2307. return redirect(request.url)
  2308. # 检查文件类型
  2309. if not file.filename.lower().endswith('.pdf'):
  2310. flash('只支持PDF文件上传')
  2311. return redirect(request.url)
  2312. # 获取表单数据
  2313. version_name = request.form.get('version_name', '').strip()
  2314. version_source = request.form.get('version_source', '').strip()
  2315. file_provider = request.form.get('file_provider', '').strip()
  2316. # 验证必填字段
  2317. if not version_name:
  2318. flash('版本名称为必填项')
  2319. return redirect(request.url)
  2320. if not version_source:
  2321. flash('版本来源为必填项')
  2322. return redirect(request.url)
  2323. # 如果未提供文件提供人,使用当前登录用户
  2324. if not file_provider:
  2325. file_provider = session.get('user_id', '未知')
  2326. import uuid
  2327. original_filename = file.filename
  2328. ext = os.path.splitext(original_filename)[1].lower()
  2329. base_name = secure_filename(original_filename)
  2330. if not base_name or base_name == ext.strip('.'):
  2331. filename = f"genealogy_pdf_{uuid.uuid4().hex[:8]}{ext}"
  2332. else:
  2333. if not base_name.lower().endswith(ext):
  2334. filename = f"{base_name}{ext}"
  2335. else:
  2336. filename = base_name
  2337. file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
  2338. file.save(file_path)
  2339. try:
  2340. # Upload to OSS
  2341. oss_url = upload_to_oss(file_path, custom_filename=filename)
  2342. if not oss_url:
  2343. flash('文件上传失败')
  2344. return redirect(request.url)
  2345. # Save to database
  2346. conn = get_db_connection()
  2347. try:
  2348. with conn.cursor() as cursor:
  2349. cursor.execute(
  2350. "INSERT INTO genealogy_pdfs (file_name, oss_url, version_name, version_source, file_provider, upload_time) VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)",
  2351. (original_filename, oss_url, version_name, version_source, file_provider)
  2352. )
  2353. conn.commit()
  2354. flash('PDF文件上传成功')
  2355. return redirect(url_for('pdf_management'))
  2356. except Exception as e:
  2357. flash(f'保存失败: {e}')
  2358. return redirect(request.url)
  2359. finally:
  2360. conn.close()
  2361. finally:
  2362. if os.path.exists(file_path):
  2363. try:
  2364. os.remove(file_path)
  2365. except:
  2366. pass
  2367. def process_pdf_pages(file_path, pdf_oss_url, uploader):
  2368. """Process PDF pages and add them to genealogy records"""
  2369. try:
  2370. import fitz
  2371. doc = fitz.open(file_path)
  2372. # Get current max page number
  2373. conn = get_db_connection()
  2374. suggested_page = 1
  2375. try:
  2376. with conn.cursor() as cursor:
  2377. cursor.execute("SELECT MAX(page_number) as max_p FROM genealogy_records")
  2378. result = cursor.fetchone()
  2379. if result and result['max_p']:
  2380. suggested_page = result['max_p'] + 1
  2381. finally:
  2382. conn.close()
  2383. for page_index in range(len(doc)):
  2384. try:
  2385. page = doc[page_index]
  2386. pix = page.get_pixmap(dpi=150)
  2387. # Save as image
  2388. img_filename = f"{os.path.splitext(os.path.basename(file_path))[0]}_page_{page_index+1}.jpg"
  2389. img_path = os.path.join(app.config['UPLOAD_FOLDER'], img_filename)
  2390. pix.save(img_path)
  2391. # Upload to OSS
  2392. img_oss_url = upload_to_oss(img_path, custom_filename=img_filename)
  2393. if img_oss_url:
  2394. # Save to genealogy_records
  2395. conn = get_db_connection()
  2396. try:
  2397. with conn.cursor() as cursor:
  2398. cursor.execute(
  2399. "INSERT INTO genealogy_records (file_name, oss_url, page_number, ai_status, upload_person, file_type) VALUES (%s, %s, %s, 1, %s, %s)",
  2400. (img_filename, img_oss_url, suggested_page + page_index, uploader, '图片')
  2401. )
  2402. record_id = cursor.lastrowid
  2403. conn.commit()
  2404. # Start AI processing
  2405. threading.Thread(target=process_ai_task, args=(record_id, img_oss_url)).start()
  2406. finally:
  2407. conn.close()
  2408. except Exception as e:
  2409. print(f"Error processing page {page_index+1}: {e}")
  2410. finally:
  2411. if 'img_path' in locals() and os.path.exists(img_path):
  2412. try:
  2413. os.remove(img_path)
  2414. except:
  2415. pass
  2416. except Exception as e:
  2417. print(f"Error processing PDF: {e}")
  2418. if __name__ == '__main__':
  2419. app.run(debug=False, port=5001)