AiRecognizer.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882
  1. <?php
  2. namespace app\common\library\ps;
  3. use think\Db;
  4. use think\Exception;
  5. /**
  6. * AI 识版执行器:取待处理任务 → 调 Ollama → 解析排版 JSON → 写入本厂模板。
  7. */
  8. class AiRecognizer
  9. {
  10. /**
  11. * 后台提交后立刻拉起 CLI,避免浏览器/Nginx 等视觉模型 1~3 分钟。
  12. * @param int $id
  13. * @return bool
  14. */
  15. public static function dispatch($id)
  16. {
  17. $id = (int)$id;
  18. if ($id <= 0) {
  19. return false;
  20. }
  21. $php = self::phpBinary();
  22. $think = ROOT_PATH . 'think';
  23. $logDir = RUNTIME_PATH . 'log';
  24. if (!is_dir($logDir)) {
  25. @mkdir($logDir, 0755, true);
  26. }
  27. $log = $logDir . DIRECTORY_SEPARATOR . 'ai_job.log';
  28. $cwd = rtrim(ROOT_PATH, '\\/');
  29. $cmd = '"' . $php . '" "' . $think . '" ps:ai-job --id=' . $id;
  30. if (IS_WIN) {
  31. self::spawnWindows($cwd, $cmd, $log, $id);
  32. } else {
  33. exec('cd ' . escapeshellarg($cwd) . ' && ' . $cmd . ' >> ' . escapeshellarg($log) . ' 2>&1 &');
  34. }
  35. return true;
  36. }
  37. /**
  38. * 处理一条识版任务。
  39. * @param int $id
  40. * @return array 任务行
  41. * @throws Exception
  42. */
  43. public static function run($id)
  44. {
  45. $id = (int)$id;
  46. $job = Db::name('ps_ai_job')->where('id', $id)->find();
  47. if (!$job) {
  48. throw new Exception('识版任务不存在');
  49. }
  50. @set_time_limit(1200);
  51. @ini_set('memory_limit', '512M');
  52. self::keepDbAlive();
  53. Db::name('ps_ai_job')->where('id', $id)->update([
  54. 'status' => 'running',
  55. 'error_msg' => '',
  56. 'updatetime' => time(),
  57. ]);
  58. try {
  59. $path = self::resolveFilePath($job['file_url']);
  60. $client = LlmClient::forRecognize();
  61. $local = LlmClient::local();
  62. $model = trim((string)$job['model']);
  63. $fileType = $job['file_type'] === 'pdf' ? 'pdf' : 'image';
  64. $raw = '';
  65. $usedModel = $model;
  66. $parsed = ['cells' => []];
  67. if ($fileType === 'pdf') {
  68. $imagePath = self::pdfFirstPageImage($path);
  69. if ($imagePath) {
  70. $got = self::extractLayout($client, $imagePath, $model);
  71. @unlink($imagePath);
  72. $raw = $got['raw'];
  73. $usedModel = $got['model'];
  74. $parsed = $got['schema'];
  75. } else {
  76. $text = self::pdfPlainText($path);
  77. if ($text === '') {
  78. throw new Exception('当前环境无法解析 PDF,请改传流程单图片(JPG/PNG)');
  79. }
  80. $raw = $local->chat(self::promptCsv() . "\n\n以下是 PDF 抽取的文字:\n" . mb_substr($text, 0, 8000), $local->textModel(), false);
  81. $usedModel = $local->textModel();
  82. $parsed = self::parseLayoutRaw($raw);
  83. }
  84. } else {
  85. $got = self::extractLayout($client, $path, $model);
  86. $raw = $got['raw'];
  87. $usedModel = $got['model'];
  88. $parsed = $got['schema'];
  89. }
  90. self::keepDbAlive();
  91. $rawStore = mb_substr((string)$raw, 0, 60000);
  92. Db::name('ps_ai_job')->where('id', $id)->update([
  93. 'model' => $usedModel,
  94. 'raw_text' => $rawStore,
  95. 'updatetime' => time(),
  96. ]);
  97. $schema = self::normalizeSchema($parsed, $job, $path);
  98. $templateId = self::saveTemplate($job, $schema);
  99. self::keepDbAlive();
  100. Db::name('ps_ai_job')->where('id', $id)->update([
  101. 'status' => 'success',
  102. 'model' => $usedModel,
  103. 'raw_text' => $rawStore,
  104. 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE),
  105. 'template_id' => $templateId,
  106. 'error_msg' => '',
  107. 'updatetime' => time(),
  108. ]);
  109. } catch (\Exception $e) {
  110. try {
  111. self::keepDbAlive();
  112. Db::name('ps_ai_job')->where('id', $id)->update([
  113. 'status' => 'fail',
  114. 'error_msg' => mb_substr($e->getMessage(), 0, 500),
  115. 'updatetime' => time(),
  116. ]);
  117. } catch (\Exception $ignore) {
  118. // 断线时至少不要让 CLI 再抛一次把状态钉死在 running
  119. }
  120. $job = ['id' => $id, 'status' => 'fail', 'error_msg' => $e->getMessage()];
  121. try {
  122. $job = Db::name('ps_ai_job')->where('id', $id)->find() ?: $job;
  123. } catch (\Exception $ignore) {
  124. }
  125. $job['status'] = 'fail';
  126. $job['error_msg'] = $e->getMessage();
  127. return $job;
  128. }
  129. self::keepDbAlive();
  130. return Db::name('ps_ai_job')->where('id', $id)->find();
  131. }
  132. /**
  133. * @param int $limit
  134. * @return int[]
  135. */
  136. public static function pendingIds($limit = 1)
  137. {
  138. $rows = Db::name('ps_ai_job')
  139. ->where('status', 'pending')
  140. ->order('id asc')
  141. ->limit(max(1, (int)$limit))
  142. ->column('id');
  143. return array_map('intval', $rows ?: []);
  144. }
  145. /**
  146. * 把上传返回的完整网址收成站内相对路径。
  147. * @param string $fileUrl
  148. * @return string
  149. */
  150. public static function localFileUrl($fileUrl)
  151. {
  152. $fileUrl = trim((string)$fileUrl);
  153. if ($fileUrl === '') {
  154. return '';
  155. }
  156. if (preg_match('#^https?://[^/]+(/.*)$#i', $fileUrl, $m)) {
  157. $fileUrl = $m[1];
  158. }
  159. $fileUrl = preg_replace('#\?.*$#', '', $fileUrl);
  160. return '/' . ltrim(str_replace('\\', '/', $fileUrl), '/');
  161. }
  162. /**
  163. * @param string $fileUrl
  164. * @return string
  165. * @throws Exception
  166. */
  167. protected static function resolveFilePath($fileUrl)
  168. {
  169. $fileUrl = self::localFileUrl($fileUrl);
  170. if ($fileUrl === '' || $fileUrl === '/') {
  171. throw new Exception('原稿文件为空');
  172. }
  173. if (preg_match('#^https?://#i', $fileUrl)) {
  174. throw new Exception('暂不支持远程 URL,请先上传到本站');
  175. }
  176. $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, ltrim($fileUrl, '/\\'));
  177. $candidates = [
  178. ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . $relative,
  179. ROOT_PATH . $relative,
  180. ];
  181. foreach ($candidates as $path) {
  182. if (is_file($path)) {
  183. return $path;
  184. }
  185. }
  186. throw new Exception('原稿文件不存在:' . $fileUrl);
  187. }
  188. /**
  189. * @param string $pdfPath
  190. * @return string 临时图片路径,失败返回空
  191. */
  192. protected static function pdfFirstPageImage($pdfPath)
  193. {
  194. if (!class_exists('\Imagick')) {
  195. return '';
  196. }
  197. try {
  198. $im = new \Imagick();
  199. $im->setResolution(120, 120);
  200. $im->readImage($pdfPath . '[0]');
  201. $im->setImageFormat('jpeg');
  202. $tmp = tempnam(sys_get_temp_dir(), 'psai') . '.jpg';
  203. $im->writeImage($tmp);
  204. $im->clear();
  205. $im->destroy();
  206. return is_file($tmp) ? $tmp : '';
  207. } catch (\Exception $e) {
  208. return '';
  209. }
  210. }
  211. /**
  212. * @param string $pdfPath
  213. * @return string
  214. */
  215. protected static function pdfPlainText($pdfPath)
  216. {
  217. $bin = IS_WIN ? 'pdftotext.exe' : 'pdftotext';
  218. $tmp = tempnam(sys_get_temp_dir(), 'pstxt');
  219. @exec(escapeshellarg($bin) . ' -enc UTF-8 ' . escapeshellarg($pdfPath) . ' ' . escapeshellarg($tmp), $out, $code);
  220. if ($code === 0 && is_file($tmp)) {
  221. $text = (string)file_get_contents($tmp);
  222. @unlink($tmp);
  223. return trim($text);
  224. }
  225. @unlink($tmp);
  226. return '';
  227. }
  228. /**
  229. * @param array $schema
  230. * @param array $job
  231. * @param string $imagePath
  232. * @return array
  233. */
  234. protected static function normalizeSchema(array $schema, array $job, $imagePath = '')
  235. {
  236. $name = trim((string)($schema['name'] ?? $schema['title'] ?? ''));
  237. $fileUrl = (string)$job['file_url'];
  238. $probe = is_file($imagePath) ? LayoutProbe::inspect($imagePath) : ['width' => 0, 'height' => 0, 'background' => '#ffffff', 'ratio' => 297 / 210];
  239. $rawItems = PrintPlanner::collectLayoutItems($schema);
  240. $elements = PrintPlanner::normalizeElements($rawItems, (int)$probe['width'], (int)$probe['height']);
  241. if (!$elements && !empty($schema['fields'])) {
  242. $elements = PrintPlanner::elementsFromFields($schema['fields']);
  243. }
  244. if ($name === '' || $name === '模板名') {
  245. foreach ($elements as $el) {
  246. if (trim((string)($el['text'] ?? '')) !== '') {
  247. $name = mb_substr(trim($el['text']), 0, 40);
  248. break;
  249. }
  250. }
  251. if ($name === '' || $name === '模板名') {
  252. $name = 'AI识版 ' . date('Y-m-d H:i');
  253. }
  254. }
  255. $processes = self::normalizeProcesses($schema['processes'] ?? []);
  256. $title = trim((string)($schema['title'] ?? $name));
  257. $pageIn = isset($schema['page']) && is_array($schema['page']) ? $schema['page'] : [];
  258. $pageW = 210;
  259. $ratio = $probe['ratio'] > 0 ? $probe['ratio'] : 297 / 210;
  260. $pageH = isset($pageIn['h']) ? (float)$pageIn['h'] : round(210 * $ratio, 2);
  261. $pageBg = $pageIn['background'] ?? $probe['background'];
  262. $cells = [];
  263. foreach ($elements as $el) {
  264. if (($el['key'] ?? '') !== '' || ($el['role'] ?? '') === 'fill' || ($el['type'] ?? '') === 'field') {
  265. $cells[] = [
  266. 'id' => $el['id'],
  267. 'key' => $el['key'],
  268. 'label' => $el['label'],
  269. 'x' => $el['x'],
  270. 'y' => $el['y'],
  271. 'w' => $el['w'],
  272. 'h' => $el['h'],
  273. ];
  274. }
  275. }
  276. return [
  277. 'name' => mb_substr($name, 0, 100),
  278. 'title' => $title,
  279. 'mode' => 'overlay',
  280. 'page' => [
  281. 'w' => $pageW,
  282. 'h' => $pageH > 0 ? $pageH : round(210 * $ratio, 2),
  283. 'background' => $pageBg ?: '#ffffff',
  284. 'image_width' => (int)$probe['width'],
  285. 'image_height' => (int)$probe['height'],
  286. ],
  287. 'elements' => $elements,
  288. 'cells' => $cells,
  289. 'fields' => [],
  290. 'processes' => $processes,
  291. 'background' => $fileUrl,
  292. 'file_url' => $fileUrl,
  293. 'source' => 'ai',
  294. ];
  295. }
  296. /**
  297. * @param array $job
  298. * @param array $schema
  299. * @return int
  300. */
  301. protected static function saveTemplate(array $job, array $schema)
  302. {
  303. $now = time();
  304. $factoryId = (int)$job['factory_id'];
  305. $userId = (int)$job['user_id'];
  306. $cover = (string)$job['file_url'];
  307. $existId = (int)($job['template_id'] ?? 0);
  308. if ($existId > 0) {
  309. $exist = Db::name('ps_template')
  310. ->where('id', $existId)
  311. ->where('factory_id', $factoryId)
  312. ->where('is_system', 0)
  313. ->find();
  314. if ($exist) {
  315. $next = (int)Db::name('ps_template_version')->where('template_id', $existId)->max('version') + 1;
  316. if ($next < 1) {
  317. $next = 1;
  318. }
  319. $versionId = (int)Db::name('ps_template_version')->insertGetId([
  320. 'template_id' => $existId,
  321. 'factory_id' => $factoryId,
  322. 'version' => $next,
  323. 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE),
  324. 'remark' => 'AI重新拆版',
  325. 'createtime' => $now,
  326. ]);
  327. Db::name('ps_template')->where('id', $existId)->update([
  328. 'name' => $schema['name'],
  329. 'cover' => $cover,
  330. 'current_version_id' => $versionId,
  331. 'updatetime' => $now,
  332. ]);
  333. return $existId;
  334. }
  335. }
  336. $templateId = (int)Db::name('ps_template')->insertGetId([
  337. 'factory_id' => $factoryId,
  338. 'creator_user_id' => $userId,
  339. 'name' => $schema['name'],
  340. 'cover' => $cover,
  341. 'source' => 'ai',
  342. 'license_type' => 'self',
  343. 'locked' => 0,
  344. 'source_template_id' => 0,
  345. 'source_version_id' => 0,
  346. 'current_version_id' => 0,
  347. 'is_system' => 0,
  348. 'status' => 'normal',
  349. 'createtime' => $now,
  350. 'updatetime' => $now,
  351. ]);
  352. $versionId = (int)Db::name('ps_template_version')->insertGetId([
  353. 'template_id' => $templateId,
  354. 'factory_id' => $factoryId,
  355. 'version' => 1,
  356. 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE),
  357. 'remark' => 'AI识版自动入库',
  358. 'createtime' => $now,
  359. ]);
  360. Db::name('ps_template')->where('id', $templateId)->update([
  361. 'current_version_id' => $versionId,
  362. 'updatetime' => $now,
  363. ]);
  364. return $templateId;
  365. }
  366. /**
  367. * @param mixed $fields
  368. * @return string[]
  369. */
  370. protected static function normalizeFields($fields)
  371. {
  372. if (!is_array($fields)) {
  373. return ['factory.name', 'product.name', 'workorder.no', 'sheet.qty'];
  374. }
  375. $alias = [
  376. '产品名称' => 'product.name', '产品' => 'product.name', '品名' => 'product.name',
  377. '产品编码' => 'product.code', '编码' => 'product.code', '货号' => 'product.code',
  378. '规格' => 'product.spec', '单位' => 'product.unit',
  379. '工厂' => 'factory.name', '厂名' => 'factory.name',
  380. '工单号' => 'workorder.no', '单号' => 'workorder.no', '流程单号' => 'workorder.no',
  381. '数量' => 'workorder.qty', '本张数量' => 'sheet.qty', '投料' => 'sheet.qty',
  382. '批次' => 'workorder.batch_no', '客户' => 'workorder.customer',
  383. '日期' => 'workorder.plan_date', '计划日' => 'workorder.plan_date',
  384. '备注' => 'workorder.remark', '操作员' => 'operator', '时间' => 'print_time',
  385. ];
  386. $allowed = [
  387. 'factory.name', 'factory.code', 'factory.contact', 'factory.mobile',
  388. 'product.code', 'product.name', 'product.spec', 'product.unit',
  389. 'workorder.no', 'workorder.qty', 'workorder.batch_no', 'workorder.plan_date', 'workorder.customer', 'workorder.remark',
  390. 'sheet.no', 'sheet.total', 'sheet.qty', 'sheet.no_text',
  391. 'process_names', 'operator', 'print_time',
  392. ];
  393. $out = [];
  394. foreach ($fields as $item) {
  395. if (is_array($item)) {
  396. $item = $item['key'] ?? $item['name'] ?? '';
  397. }
  398. $item = trim((string)$item);
  399. if (isset($alias[$item])) {
  400. $item = $alias[$item];
  401. }
  402. if (in_array($item, $allowed, true) && !in_array($item, $out, true)) {
  403. $out[] = $item;
  404. }
  405. }
  406. return $out ?: ['factory.name', 'product.name', 'workorder.no', 'sheet.qty'];
  407. }
  408. /**
  409. * @param mixed $processes
  410. * @return array
  411. */
  412. protected static function normalizeProcesses($processes)
  413. {
  414. if (!is_array($processes)) {
  415. return [];
  416. }
  417. $list = [];
  418. $seq = 1;
  419. foreach ($processes as $row) {
  420. if (is_string($row) && trim($row) !== '') {
  421. $list[] = [
  422. 'seq' => $seq++,
  423. 'name' => trim($row),
  424. 'workstation' => '',
  425. 'standard_time' => '',
  426. 'qc_points' => '',
  427. ];
  428. continue;
  429. }
  430. if (!is_array($row) || empty($row['name'])) {
  431. continue;
  432. }
  433. $list[] = [
  434. 'seq' => isset($row['seq']) ? (int)$row['seq'] : $seq++,
  435. 'name' => (string)$row['name'],
  436. 'workstation' => isset($row['workstation']) ? (string)$row['workstation'] : '',
  437. 'standard_time' => isset($row['standard_time']) ? (string)$row['standard_time'] : '',
  438. 'qc_points' => isset($row['qc_points']) ? (string)$row['qc_points'] : '',
  439. ];
  440. }
  441. return $list;
  442. }
  443. /**
  444. * @param array $schema
  445. * @return string
  446. */
  447. protected static function fallbackHtml(array $schema)
  448. {
  449. $title = htmlspecialchars((string)($schema['title'] ?? $schema['name'] ?? '生产流程单'), ENT_QUOTES, 'UTF-8');
  450. return '<div style="font-family:SimSun,serif;font-size:14px;padding:12px;">'
  451. . '<h2 style="text-align:center;margin:0 0 8px;">' . $title . '</h2>'
  452. . '<div style="text-align:right;margin-bottom:8px;">第 {sheet.no_text} 张</div>'
  453. . '<table border="1" cellspacing="0" cellpadding="6" width="100%" style="border-collapse:collapse;">'
  454. . '<tr><td>工厂</td><td>{factory.name}</td><td>工单号</td><td>{workorder.no}</td></tr>'
  455. . '<tr><td>产品</td><td>{product.name}</td><td>编码</td><td>{product.code}</td></tr>'
  456. . '<tr><td>规格</td><td>{product.spec}</td><td>单位</td><td>{product.unit}</td></tr>'
  457. . '<tr><td>批次</td><td>{workorder.batch_no}</td><td>客户</td><td>{workorder.customer}</td></tr>'
  458. . '<tr><td>本张数量</td><td>{sheet.qty}</td><td>操作员</td><td>{operator}</td></tr>'
  459. . '</table>'
  460. . '<h3 style="margin:12px 0 6px;">工序</h3>{processes.html}'
  461. . '<p>备注:{workorder.remark}</p>'
  462. . '<p style="text-align:right;color:#666;">打印时间:{print_time}</p>'
  463. . '</div>';
  464. }
  465. /**
  466. * 拆版:线上视觉优先;本地只作简单备用(横条切图、弱模型 CSV)。
  467. *
  468. * @param LlmClient $client
  469. * @param string $path
  470. * @param string|null $preferredModel
  471. * @return array {raw, schema, model, score}
  472. * @throws Exception
  473. */
  474. protected static function extractLayout(LlmClient $client, $path, $preferredModel = null)
  475. {
  476. $preferredModel = trim((string)$preferredModel);
  477. $online = LlmClient::online();
  478. $local = LlmClient::local();
  479. $best = ['raw' => '', 'schema' => ['cells' => []], 'model' => $preferredModel, 'score' => -1];
  480. $preferLocal = LlmClient::isLocalModelName($preferredModel);
  481. if (!$preferLocal && $online->enabled()) {
  482. $model = ($preferredModel !== '' && !LlmClient::isLocalModelName($preferredModel))
  483. ? $preferredModel
  484. : $online->visionModel();
  485. try {
  486. $raw = $online->visionRaw(self::promptOnline(), $path, $model, true);
  487. $schema = self::parseLayoutRaw($raw);
  488. $score = self::layoutScore($schema);
  489. $best = ['raw' => $raw, 'schema' => $schema, 'model' => $model, 'score' => $score];
  490. if ($score >= 3) {
  491. return $best;
  492. }
  493. } catch (\Exception $e) {
  494. throw new Exception('线上识版失败:' . $e->getMessage());
  495. }
  496. throw new Exception('线上识版没有读出完整格子。请核对视觉模型名(需带视觉能力)。');
  497. }
  498. $models = [];
  499. if ($preferLocal && $preferredModel !== '') {
  500. $models[] = $preferredModel;
  501. }
  502. foreach ([$local->visionModel(), $local->visionModelComplex()] as $m) {
  503. if ($m !== '' && !in_array($m, $models, true)) {
  504. $models[] = $m;
  505. }
  506. }
  507. if (!$models) {
  508. if ($best['score'] >= 3) {
  509. return $best;
  510. }
  511. throw new Exception('未配置视觉模型。请在后台「大模型设置」填写线上视觉模型,或保留本地 Ollama 视觉模型。');
  512. }
  513. foreach ($models as $model) {
  514. $raw = $local->visionRaw(self::promptCsv(), $path, $model, false);
  515. $schema = self::parseLayoutRaw($raw);
  516. $score = self::layoutScore($schema);
  517. if ($score > $best['score']) {
  518. $best = ['raw' => $raw, 'schema' => $schema, 'model' => $model, 'score' => $score];
  519. }
  520. if ($score < 8) {
  521. $tiled = self::extractTiles($local, $path, $model);
  522. if ($tiled['score'] > $best['score']) {
  523. $best = $tiled;
  524. }
  525. }
  526. if ($best['score'] >= 12) {
  527. break;
  528. }
  529. }
  530. if ($best['score'] < 3) {
  531. if ($online->enabled()) {
  532. throw new Exception('线上识版没有读出完整格子。请核对 API Key、接口地址和视觉模型名(需带视觉能力,如 qwen-vl-max / gpt-4o)。');
  533. }
  534. throw new Exception('当前本地视觉模型读不出流程单格子。请在后台「大模型设置」启用线上视觉模型后再拆版。');
  535. }
  536. return $best;
  537. }
  538. /**
  539. * @param LlmClient $client
  540. * @param string $path
  541. * @param string $model
  542. * @return array
  543. */
  544. protected static function extractTiles(LlmClient $client, $path, $model)
  545. {
  546. $bands = self::splitImageBands($path, 3);
  547. if (!$bands) {
  548. return ['raw' => '', 'schema' => ['cells' => []], 'model' => $model, 'score' => 0];
  549. }
  550. $cells = [];
  551. $rawParts = [];
  552. foreach ($bands as $i => $band) {
  553. try {
  554. $raw = $client->visionRaw(self::promptTile($i + 1, count($bands)), $band['path'], $model, false);
  555. } catch (\Exception $e) {
  556. $raw = '';
  557. }
  558. $rawParts[] = '---band' . ($i + 1) . "---\n" . $raw;
  559. $part = self::parseLayoutRaw($raw);
  560. foreach (($part['cells'] ?? []) as $cell) {
  561. $y = (float)($cell['y'] ?? 0);
  562. $h = (float)($cell['h'] ?? 4);
  563. $cell['y'] = round(($band['y0'] + $y / 100 * $band['h']) / $band['fullH'] * 100, 2);
  564. $cell['h'] = max(1.2, round($h * $band['h'] / $band['fullH'], 2));
  565. $cells[] = $cell;
  566. }
  567. if (is_file($band['path'])) {
  568. @unlink($band['path']);
  569. }
  570. }
  571. $schema = ['name' => '', 'cells' => $cells];
  572. return [
  573. 'raw' => implode("\n", $rawParts),
  574. 'schema' => $schema,
  575. 'model' => $model,
  576. 'score' => self::layoutScore($schema),
  577. ];
  578. }
  579. /**
  580. * @param string $path
  581. * @param int $bands
  582. * @return array
  583. */
  584. protected static function splitImageBands($path, $bands = 4)
  585. {
  586. if (!is_file($path) || !function_exists('imagecreatefromstring')) {
  587. return [];
  588. }
  589. $bin = @file_get_contents($path);
  590. $im = $bin ? @imagecreatefromstring($bin) : false;
  591. if (!$im) {
  592. return [];
  593. }
  594. $w = imagesx($im);
  595. $h = imagesy($im);
  596. $overlap = (int)($h * 0.08);
  597. $bandH = (int)ceil($h / $bands);
  598. $out = [];
  599. for ($i = 0; $i < $bands; $i++) {
  600. $y = max(0, $i * $bandH - ($i ? $overlap : 0));
  601. $bh = min($h - $y, $bandH + $overlap);
  602. if ($bh < 20) {
  603. continue;
  604. }
  605. $dst = imagecreatetruecolor($w, $bh);
  606. imagecopy($dst, $im, 0, 0, 0, $y, $w, $bh);
  607. $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'psband_' . $i . '_' . uniqid() . '.jpg';
  608. imagejpeg($dst, $tmp, 86);
  609. imagedestroy($dst);
  610. $out[] = ['path' => $tmp, 'y0' => $y, 'h' => $bh, 'fullH' => $h, 'fullW' => $w];
  611. }
  612. imagedestroy($im);
  613. return $out;
  614. }
  615. /**
  616. * @param string $raw
  617. * @return array
  618. */
  619. protected static function parseLayoutRaw($raw)
  620. {
  621. $raw = trim((string)$raw);
  622. if ($raw !== '' && (strpos($raw, '{') !== false || strpos($raw, '[') !== false)) {
  623. try {
  624. $json = LlmClient::extractJson($raw);
  625. if (is_array($json) && (!empty($json['cells']) || !empty($json['elements']) || !empty($json['fields']))) {
  626. if (self::layoutScore($json) >= 3) {
  627. return $json;
  628. }
  629. }
  630. } catch (\Exception $e) {
  631. // CSV / 非 JSON 走下面的管道行解析
  632. }
  633. }
  634. $cells = [];
  635. foreach (preg_split("/\r\n|\n|\r/", $raw) as $line) {
  636. $line = trim($line);
  637. if ($line === '' || strpos($line, '|') === false) {
  638. continue;
  639. }
  640. $line = ltrim($line, "-* \t");
  641. $parts = array_map('trim', explode('|', $line));
  642. if (count($parts) < 6) {
  643. continue;
  644. }
  645. $head = mb_strtolower($parts[0]);
  646. if (in_array($head, ['text', '文字', '内容'], true)) {
  647. continue;
  648. }
  649. $x = self::toPct($parts[1]);
  650. $y = self::toPct($parts[2]);
  651. $w = self::toPct($parts[3]);
  652. $h = self::toPct($parts[4]);
  653. if ($w <= 0 || $h <= 0) {
  654. continue;
  655. }
  656. $role = mb_strtolower($parts[5]);
  657. $role = (strpos($role, 'fill') !== false || $role === '空' || $role === '填写') ? 'fill' : 'label';
  658. $key = isset($parts[6]) ? trim($parts[6]) : '';
  659. $color = isset($parts[7]) && preg_match('/^#?[0-9a-fA-F]{3,8}$/', $parts[7]) ? $parts[7] : '#000000';
  660. if ($color && $color[0] !== '#') {
  661. $color = '#' . $color;
  662. }
  663. $bg = isset($parts[8]) && preg_match('/^#?[0-9a-fA-F]{3,8}$/', $parts[8]) ? $parts[8] : '#ffffff';
  664. if ($bg && $bg[0] !== '#') {
  665. $bg = '#' . $bg;
  666. }
  667. $fontSize = isset($parts[9]) ? (int)$parts[9] : 13;
  668. $cells[] = [
  669. 'text' => $parts[0],
  670. 'x' => $x,
  671. 'y' => $y,
  672. 'w' => $w,
  673. 'h' => $h,
  674. 'role' => $role,
  675. 'key' => $role === 'fill' ? $key : '',
  676. 'fontSize' => $fontSize > 0 ? $fontSize : 13,
  677. 'color' => $color ?: '#000000',
  678. 'background' => $bg ?: '#ffffff',
  679. 'borders' => 'tblr',
  680. ];
  681. }
  682. return ['cells' => $cells];
  683. }
  684. /**
  685. * @param mixed $v
  686. * @return float
  687. */
  688. protected static function toPct($v)
  689. {
  690. $n = (float)preg_replace('/[^0-9.\-]/', '', (string)$v);
  691. if ($n < 0) {
  692. $n = 0;
  693. }
  694. if ($n > 100) {
  695. $n = 100;
  696. }
  697. return round($n, 2);
  698. }
  699. /**
  700. * @param array $schema
  701. * @return int
  702. */
  703. protected static function layoutScore(array $schema)
  704. {
  705. $items = [];
  706. foreach (['cells', 'elements', 'fields'] as $k) {
  707. if (!empty($schema[$k]) && is_array($schema[$k])) {
  708. $items = array_merge($items, $schema[$k]);
  709. }
  710. }
  711. $n = count($items);
  712. if ($n <= 2) {
  713. $texts = [];
  714. foreach ($items as $it) {
  715. $texts[] = trim((string)($it['text'] ?? $it['key'] ?? ''));
  716. }
  717. $joined = implode(' ', $texts);
  718. if (mb_strpos($joined, '产品名称') !== false && $n <= 2) {
  719. return 0;
  720. }
  721. }
  722. $uniq = [];
  723. foreach ($items as $it) {
  724. $t = trim((string)($it['text'] ?? $it['key'] ?? $it['label'] ?? ''));
  725. if ($t !== '') {
  726. $uniq[$t] = 1;
  727. } else {
  728. $uniq['fill:' . ($it['x'] ?? '') . ',' . ($it['y'] ?? '')] = 1;
  729. }
  730. }
  731. return count($uniq);
  732. }
  733. /**
  734. * @return string
  735. */
  736. protected static function promptCsv()
  737. {
  738. return <<<'PROMPT'
  739. 看这张印刷流程单照片,只找出空白填写格(还没写字的空格子)。不要解释,不要 JSON,不要抄示例。已印好的标题不要列出,它们会留在底图上。
  740. 每行格式:
  741. 旁标题|x|y|宽|高|fill|字段key|#111111|#transparent|13
  742. 规则:
  743. - x y 宽 高是空格相对整张图的百分比,0 到 100
  744. - 根据格子旁边已印好的标题猜字段key,只能从这些选:factory.name product.code product.name product.spec product.unit workorder.no workorder.qty workorder.batch_no workorder.plan_date workorder.customer workorder.remark sheet.qty sheet.no_text process_names operator print_time ;对不上就留空
  745. - 至少列出图上所有空格,禁止只写一两行
  746. PROMPT;
  747. }
  748. /**
  749. * @param int $index
  750. * @param int $total
  751. * @return string
  752. */
  753. protected static function promptTile($index, $total)
  754. {
  755. return '这是流程单的第' . $index . '/' . $total . "横条局部。只列出空白填写格,不要抄示例,不要 JSON。每行:旁标题|x|y|宽|高|fill|字段key|#111111|#transparent|13\n"
  756. . "x y 宽 高是本张局部图的百分比 0-100。已印好的字不要列。";
  757. }
  758. /**
  759. * 线上视觉模型用 JSON。不要给可抄的两格示例。
  760. * @return string
  761. */
  762. protected static function promptOnline()
  763. {
  764. return <<<'PROMPT'
  765. 你是流程单套打定位引擎。只输出 JSON,不要解释、不要 Markdown。
  766. 打印时会把这张原图当底纸,只在空白格填工单/产品数据。因此只标出空白填写格,不要拆已印好的标题和线框。
  767. 根据空格旁边的印字猜测 key。x/y/w/h 是相对整张图的百分比 0~100。
  768. key 只能从这些选,对不上就留空:
  769. factory.name product.code product.name product.spec product.unit workorder.no workorder.qty workorder.batch_no workorder.plan_date workorder.customer workorder.remark sheet.qty sheet.no_text process_names operator print_time
  770. 把图上所有空格都列出来。
  771. {"name":"单据标题","mode":"overlay","cells":[{"text":"","x":10,"y":8,"w":18,"h":4,"role":"fill","key":"workorder.no","label":"工单号","fontSize":13,"align":"left","color":"#111111"}]}
  772. PROMPT;
  773. }
  774. /**
  775. * 识版会打很久,phpstudy 的 MySQL wait_timeout=120,必须先拉长会话再重连。
  776. */
  777. protected static function keepDbAlive()
  778. {
  779. try {
  780. Db::execute('SELECT 1');
  781. } catch (\Exception $e) {
  782. Db::connect([], true);
  783. }
  784. try {
  785. Db::execute('SET SESSION wait_timeout=3600, SESSION interactive_timeout=3600');
  786. } catch (\Exception $e) {
  787. // ignore
  788. }
  789. }
  790. /**
  791. * Windows 下 php-cgi 里 popen(start /B) 会等到子进程结束,Nginx 60 秒就 504。
  792. * 必须真正脱离当前 FastCGI 请求。
  793. * @param string $cwd
  794. * @param string $cmd
  795. * @param string $log
  796. * @param int $id
  797. */
  798. protected static function spawnWindows($cwd, $cmd, $log, $id)
  799. {
  800. $line = 'cd /d "' . $cwd . '" && ' . $cmd . ' >> "' . $log . '" 2>&1';
  801. if (class_exists('COM', false)) {
  802. try {
  803. $wsh = new \COM('WScript.Shell');
  804. $wsh->Run('cmd /c ' . $line, 0, false);
  805. return;
  806. } catch (\Exception $e) {
  807. // 走 start 兜底
  808. }
  809. }
  810. $bat = RUNTIME_PATH . 'ai_job_' . (int)$id . '.cmd';
  811. @file_put_contents($bat, "@echo off\r\n" . $line . "\r\n");
  812. if (is_file($bat)) {
  813. pclose(popen('start "ps-ai-' . (int)$id . '" /B "' . $bat . '"', 'r'));
  814. return;
  815. }
  816. pclose(popen('start "ps-ai-' . (int)$id . '" /B cmd /c "' . str_replace('"', '""', $line) . '"', 'r'));
  817. }
  818. /**
  819. * @return string
  820. */
  821. protected static function phpBinary()
  822. {
  823. $php = PHP_BINARY ?: '';
  824. if ($php && stripos($php, 'cgi') === false && stripos($php, 'fpm') === false && is_file($php)) {
  825. return $php;
  826. }
  827. $dir = $php ? dirname($php) : '';
  828. $candidates = [];
  829. if ($dir) {
  830. $candidates[] = $dir . DIRECTORY_SEPARATOR . 'php.exe';
  831. $candidates[] = $dir . DIRECTORY_SEPARATOR . 'php';
  832. }
  833. $candidates[] = 'D:\\phpstudy_pro\\Extensions\\php\\php7.4.3nts\\php.exe';
  834. foreach ($candidates as $candidate) {
  835. if ($candidate && is_file($candidate)) {
  836. return $candidate;
  837. }
  838. }
  839. return 'php';
  840. }
  841. }