| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882 |
- <?php
- namespace app\common\library\ps;
- use think\Db;
- use think\Exception;
- /**
- * AI 识版执行器:取待处理任务 → 调 Ollama → 解析排版 JSON → 写入本厂模板。
- */
- class AiRecognizer
- {
- /**
- * 后台提交后立刻拉起 CLI,避免浏览器/Nginx 等视觉模型 1~3 分钟。
- * @param int $id
- * @return bool
- */
- public static function dispatch($id)
- {
- $id = (int)$id;
- if ($id <= 0) {
- return false;
- }
- $php = self::phpBinary();
- $think = ROOT_PATH . 'think';
- $logDir = RUNTIME_PATH . 'log';
- if (!is_dir($logDir)) {
- @mkdir($logDir, 0755, true);
- }
- $log = $logDir . DIRECTORY_SEPARATOR . 'ai_job.log';
- $cwd = rtrim(ROOT_PATH, '\\/');
- $cmd = '"' . $php . '" "' . $think . '" ps:ai-job --id=' . $id;
- if (IS_WIN) {
- self::spawnWindows($cwd, $cmd, $log, $id);
- } else {
- exec('cd ' . escapeshellarg($cwd) . ' && ' . $cmd . ' >> ' . escapeshellarg($log) . ' 2>&1 &');
- }
- return true;
- }
- /**
- * 处理一条识版任务。
- * @param int $id
- * @return array 任务行
- * @throws Exception
- */
- public static function run($id)
- {
- $id = (int)$id;
- $job = Db::name('ps_ai_job')->where('id', $id)->find();
- if (!$job) {
- throw new Exception('识版任务不存在');
- }
- @set_time_limit(1200);
- @ini_set('memory_limit', '512M');
- self::keepDbAlive();
- Db::name('ps_ai_job')->where('id', $id)->update([
- 'status' => 'running',
- 'error_msg' => '',
- 'updatetime' => time(),
- ]);
- try {
- $path = self::resolveFilePath($job['file_url']);
- $client = LlmClient::forRecognize();
- $local = LlmClient::local();
- $model = trim((string)$job['model']);
- $fileType = $job['file_type'] === 'pdf' ? 'pdf' : 'image';
- $raw = '';
- $usedModel = $model;
- $parsed = ['cells' => []];
- if ($fileType === 'pdf') {
- $imagePath = self::pdfFirstPageImage($path);
- if ($imagePath) {
- $got = self::extractLayout($client, $imagePath, $model);
- @unlink($imagePath);
- $raw = $got['raw'];
- $usedModel = $got['model'];
- $parsed = $got['schema'];
- } else {
- $text = self::pdfPlainText($path);
- if ($text === '') {
- throw new Exception('当前环境无法解析 PDF,请改传流程单图片(JPG/PNG)');
- }
- $raw = $local->chat(self::promptCsv() . "\n\n以下是 PDF 抽取的文字:\n" . mb_substr($text, 0, 8000), $local->textModel(), false);
- $usedModel = $local->textModel();
- $parsed = self::parseLayoutRaw($raw);
- }
- } else {
- $got = self::extractLayout($client, $path, $model);
- $raw = $got['raw'];
- $usedModel = $got['model'];
- $parsed = $got['schema'];
- }
- self::keepDbAlive();
- $rawStore = mb_substr((string)$raw, 0, 60000);
- Db::name('ps_ai_job')->where('id', $id)->update([
- 'model' => $usedModel,
- 'raw_text' => $rawStore,
- 'updatetime' => time(),
- ]);
- $schema = self::normalizeSchema($parsed, $job, $path);
- $templateId = self::saveTemplate($job, $schema);
- self::keepDbAlive();
- Db::name('ps_ai_job')->where('id', $id)->update([
- 'status' => 'success',
- 'model' => $usedModel,
- 'raw_text' => $rawStore,
- 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE),
- 'template_id' => $templateId,
- 'error_msg' => '',
- 'updatetime' => time(),
- ]);
- } catch (\Exception $e) {
- try {
- self::keepDbAlive();
- Db::name('ps_ai_job')->where('id', $id)->update([
- 'status' => 'fail',
- 'error_msg' => mb_substr($e->getMessage(), 0, 500),
- 'updatetime' => time(),
- ]);
- } catch (\Exception $ignore) {
- // 断线时至少不要让 CLI 再抛一次把状态钉死在 running
- }
- $job = ['id' => $id, 'status' => 'fail', 'error_msg' => $e->getMessage()];
- try {
- $job = Db::name('ps_ai_job')->where('id', $id)->find() ?: $job;
- } catch (\Exception $ignore) {
- }
- $job['status'] = 'fail';
- $job['error_msg'] = $e->getMessage();
- return $job;
- }
- self::keepDbAlive();
- return Db::name('ps_ai_job')->where('id', $id)->find();
- }
- /**
- * @param int $limit
- * @return int[]
- */
- public static function pendingIds($limit = 1)
- {
- $rows = Db::name('ps_ai_job')
- ->where('status', 'pending')
- ->order('id asc')
- ->limit(max(1, (int)$limit))
- ->column('id');
- return array_map('intval', $rows ?: []);
- }
- /**
- * 把上传返回的完整网址收成站内相对路径。
- * @param string $fileUrl
- * @return string
- */
- public static function localFileUrl($fileUrl)
- {
- $fileUrl = trim((string)$fileUrl);
- if ($fileUrl === '') {
- return '';
- }
- if (preg_match('#^https?://[^/]+(/.*)$#i', $fileUrl, $m)) {
- $fileUrl = $m[1];
- }
- $fileUrl = preg_replace('#\?.*$#', '', $fileUrl);
- return '/' . ltrim(str_replace('\\', '/', $fileUrl), '/');
- }
- /**
- * @param string $fileUrl
- * @return string
- * @throws Exception
- */
- protected static function resolveFilePath($fileUrl)
- {
- $fileUrl = self::localFileUrl($fileUrl);
- if ($fileUrl === '' || $fileUrl === '/') {
- throw new Exception('原稿文件为空');
- }
- if (preg_match('#^https?://#i', $fileUrl)) {
- throw new Exception('暂不支持远程 URL,请先上传到本站');
- }
- $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, ltrim($fileUrl, '/\\'));
- $candidates = [
- ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . $relative,
- ROOT_PATH . $relative,
- ];
- foreach ($candidates as $path) {
- if (is_file($path)) {
- return $path;
- }
- }
- throw new Exception('原稿文件不存在:' . $fileUrl);
- }
- /**
- * @param string $pdfPath
- * @return string 临时图片路径,失败返回空
- */
- protected static function pdfFirstPageImage($pdfPath)
- {
- if (!class_exists('\Imagick')) {
- return '';
- }
- try {
- $im = new \Imagick();
- $im->setResolution(120, 120);
- $im->readImage($pdfPath . '[0]');
- $im->setImageFormat('jpeg');
- $tmp = tempnam(sys_get_temp_dir(), 'psai') . '.jpg';
- $im->writeImage($tmp);
- $im->clear();
- $im->destroy();
- return is_file($tmp) ? $tmp : '';
- } catch (\Exception $e) {
- return '';
- }
- }
- /**
- * @param string $pdfPath
- * @return string
- */
- protected static function pdfPlainText($pdfPath)
- {
- $bin = IS_WIN ? 'pdftotext.exe' : 'pdftotext';
- $tmp = tempnam(sys_get_temp_dir(), 'pstxt');
- @exec(escapeshellarg($bin) . ' -enc UTF-8 ' . escapeshellarg($pdfPath) . ' ' . escapeshellarg($tmp), $out, $code);
- if ($code === 0 && is_file($tmp)) {
- $text = (string)file_get_contents($tmp);
- @unlink($tmp);
- return trim($text);
- }
- @unlink($tmp);
- return '';
- }
- /**
- * @param array $schema
- * @param array $job
- * @param string $imagePath
- * @return array
- */
- protected static function normalizeSchema(array $schema, array $job, $imagePath = '')
- {
- $name = trim((string)($schema['name'] ?? $schema['title'] ?? ''));
- $fileUrl = (string)$job['file_url'];
- $probe = is_file($imagePath) ? LayoutProbe::inspect($imagePath) : ['width' => 0, 'height' => 0, 'background' => '#ffffff', 'ratio' => 297 / 210];
- $rawItems = PrintPlanner::collectLayoutItems($schema);
- $elements = PrintPlanner::normalizeElements($rawItems, (int)$probe['width'], (int)$probe['height']);
- if (!$elements && !empty($schema['fields'])) {
- $elements = PrintPlanner::elementsFromFields($schema['fields']);
- }
- if ($name === '' || $name === '模板名') {
- foreach ($elements as $el) {
- if (trim((string)($el['text'] ?? '')) !== '') {
- $name = mb_substr(trim($el['text']), 0, 40);
- break;
- }
- }
- if ($name === '' || $name === '模板名') {
- $name = 'AI识版 ' . date('Y-m-d H:i');
- }
- }
- $processes = self::normalizeProcesses($schema['processes'] ?? []);
- $title = trim((string)($schema['title'] ?? $name));
- $pageIn = isset($schema['page']) && is_array($schema['page']) ? $schema['page'] : [];
- $pageW = 210;
- $ratio = $probe['ratio'] > 0 ? $probe['ratio'] : 297 / 210;
- $pageH = isset($pageIn['h']) ? (float)$pageIn['h'] : round(210 * $ratio, 2);
- $pageBg = $pageIn['background'] ?? $probe['background'];
- $cells = [];
- foreach ($elements as $el) {
- if (($el['key'] ?? '') !== '' || ($el['role'] ?? '') === 'fill' || ($el['type'] ?? '') === 'field') {
- $cells[] = [
- 'id' => $el['id'],
- 'key' => $el['key'],
- 'label' => $el['label'],
- 'x' => $el['x'],
- 'y' => $el['y'],
- 'w' => $el['w'],
- 'h' => $el['h'],
- ];
- }
- }
- return [
- 'name' => mb_substr($name, 0, 100),
- 'title' => $title,
- 'mode' => 'overlay',
- 'page' => [
- 'w' => $pageW,
- 'h' => $pageH > 0 ? $pageH : round(210 * $ratio, 2),
- 'background' => $pageBg ?: '#ffffff',
- 'image_width' => (int)$probe['width'],
- 'image_height' => (int)$probe['height'],
- ],
- 'elements' => $elements,
- 'cells' => $cells,
- 'fields' => [],
- 'processes' => $processes,
- 'background' => $fileUrl,
- 'file_url' => $fileUrl,
- 'source' => 'ai',
- ];
- }
- /**
- * @param array $job
- * @param array $schema
- * @return int
- */
- protected static function saveTemplate(array $job, array $schema)
- {
- $now = time();
- $factoryId = (int)$job['factory_id'];
- $userId = (int)$job['user_id'];
- $cover = (string)$job['file_url'];
- $existId = (int)($job['template_id'] ?? 0);
- if ($existId > 0) {
- $exist = Db::name('ps_template')
- ->where('id', $existId)
- ->where('factory_id', $factoryId)
- ->where('is_system', 0)
- ->find();
- if ($exist) {
- $next = (int)Db::name('ps_template_version')->where('template_id', $existId)->max('version') + 1;
- if ($next < 1) {
- $next = 1;
- }
- $versionId = (int)Db::name('ps_template_version')->insertGetId([
- 'template_id' => $existId,
- 'factory_id' => $factoryId,
- 'version' => $next,
- 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE),
- 'remark' => 'AI重新拆版',
- 'createtime' => $now,
- ]);
- Db::name('ps_template')->where('id', $existId)->update([
- 'name' => $schema['name'],
- 'cover' => $cover,
- 'current_version_id' => $versionId,
- 'updatetime' => $now,
- ]);
- return $existId;
- }
- }
- $templateId = (int)Db::name('ps_template')->insertGetId([
- 'factory_id' => $factoryId,
- 'creator_user_id' => $userId,
- 'name' => $schema['name'],
- 'cover' => $cover,
- 'source' => 'ai',
- 'license_type' => 'self',
- 'locked' => 0,
- 'source_template_id' => 0,
- 'source_version_id' => 0,
- 'current_version_id' => 0,
- 'is_system' => 0,
- 'status' => 'normal',
- 'createtime' => $now,
- 'updatetime' => $now,
- ]);
- $versionId = (int)Db::name('ps_template_version')->insertGetId([
- 'template_id' => $templateId,
- 'factory_id' => $factoryId,
- 'version' => 1,
- 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE),
- 'remark' => 'AI识版自动入库',
- 'createtime' => $now,
- ]);
- Db::name('ps_template')->where('id', $templateId)->update([
- 'current_version_id' => $versionId,
- 'updatetime' => $now,
- ]);
- return $templateId;
- }
- /**
- * @param mixed $fields
- * @return string[]
- */
- protected static function normalizeFields($fields)
- {
- if (!is_array($fields)) {
- return ['factory.name', 'product.name', 'workorder.no', 'sheet.qty'];
- }
- $alias = [
- '产品名称' => 'product.name', '产品' => 'product.name', '品名' => 'product.name',
- '产品编码' => 'product.code', '编码' => 'product.code', '货号' => 'product.code',
- '规格' => 'product.spec', '单位' => 'product.unit',
- '工厂' => 'factory.name', '厂名' => 'factory.name',
- '工单号' => 'workorder.no', '单号' => 'workorder.no', '流程单号' => 'workorder.no',
- '数量' => 'workorder.qty', '本张数量' => 'sheet.qty', '投料' => 'sheet.qty',
- '批次' => 'workorder.batch_no', '客户' => 'workorder.customer',
- '日期' => 'workorder.plan_date', '计划日' => 'workorder.plan_date',
- '备注' => 'workorder.remark', '操作员' => 'operator', '时间' => 'print_time',
- ];
- $allowed = [
- 'factory.name', 'factory.code', 'factory.contact', 'factory.mobile',
- 'product.code', 'product.name', 'product.spec', 'product.unit',
- 'workorder.no', 'workorder.qty', 'workorder.batch_no', 'workorder.plan_date', 'workorder.customer', 'workorder.remark',
- 'sheet.no', 'sheet.total', 'sheet.qty', 'sheet.no_text',
- 'process_names', 'operator', 'print_time',
- ];
- $out = [];
- foreach ($fields as $item) {
- if (is_array($item)) {
- $item = $item['key'] ?? $item['name'] ?? '';
- }
- $item = trim((string)$item);
- if (isset($alias[$item])) {
- $item = $alias[$item];
- }
- if (in_array($item, $allowed, true) && !in_array($item, $out, true)) {
- $out[] = $item;
- }
- }
- return $out ?: ['factory.name', 'product.name', 'workorder.no', 'sheet.qty'];
- }
- /**
- * @param mixed $processes
- * @return array
- */
- protected static function normalizeProcesses($processes)
- {
- if (!is_array($processes)) {
- return [];
- }
- $list = [];
- $seq = 1;
- foreach ($processes as $row) {
- if (is_string($row) && trim($row) !== '') {
- $list[] = [
- 'seq' => $seq++,
- 'name' => trim($row),
- 'workstation' => '',
- 'standard_time' => '',
- 'qc_points' => '',
- ];
- continue;
- }
- if (!is_array($row) || empty($row['name'])) {
- continue;
- }
- $list[] = [
- 'seq' => isset($row['seq']) ? (int)$row['seq'] : $seq++,
- 'name' => (string)$row['name'],
- 'workstation' => isset($row['workstation']) ? (string)$row['workstation'] : '',
- 'standard_time' => isset($row['standard_time']) ? (string)$row['standard_time'] : '',
- 'qc_points' => isset($row['qc_points']) ? (string)$row['qc_points'] : '',
- ];
- }
- return $list;
- }
- /**
- * @param array $schema
- * @return string
- */
- protected static function fallbackHtml(array $schema)
- {
- $title = htmlspecialchars((string)($schema['title'] ?? $schema['name'] ?? '生产流程单'), ENT_QUOTES, 'UTF-8');
- return '<div style="font-family:SimSun,serif;font-size:14px;padding:12px;">'
- . '<h2 style="text-align:center;margin:0 0 8px;">' . $title . '</h2>'
- . '<div style="text-align:right;margin-bottom:8px;">第 {sheet.no_text} 张</div>'
- . '<table border="1" cellspacing="0" cellpadding="6" width="100%" style="border-collapse:collapse;">'
- . '<tr><td>工厂</td><td>{factory.name}</td><td>工单号</td><td>{workorder.no}</td></tr>'
- . '<tr><td>产品</td><td>{product.name}</td><td>编码</td><td>{product.code}</td></tr>'
- . '<tr><td>规格</td><td>{product.spec}</td><td>单位</td><td>{product.unit}</td></tr>'
- . '<tr><td>批次</td><td>{workorder.batch_no}</td><td>客户</td><td>{workorder.customer}</td></tr>'
- . '<tr><td>本张数量</td><td>{sheet.qty}</td><td>操作员</td><td>{operator}</td></tr>'
- . '</table>'
- . '<h3 style="margin:12px 0 6px;">工序</h3>{processes.html}'
- . '<p>备注:{workorder.remark}</p>'
- . '<p style="text-align:right;color:#666;">打印时间:{print_time}</p>'
- . '</div>';
- }
- /**
- * 拆版:线上视觉优先;本地只作简单备用(横条切图、弱模型 CSV)。
- *
- * @param LlmClient $client
- * @param string $path
- * @param string|null $preferredModel
- * @return array {raw, schema, model, score}
- * @throws Exception
- */
- protected static function extractLayout(LlmClient $client, $path, $preferredModel = null)
- {
- $preferredModel = trim((string)$preferredModel);
- $online = LlmClient::online();
- $local = LlmClient::local();
- $best = ['raw' => '', 'schema' => ['cells' => []], 'model' => $preferredModel, 'score' => -1];
- $preferLocal = LlmClient::isLocalModelName($preferredModel);
- if (!$preferLocal && $online->enabled()) {
- $model = ($preferredModel !== '' && !LlmClient::isLocalModelName($preferredModel))
- ? $preferredModel
- : $online->visionModel();
- try {
- $raw = $online->visionRaw(self::promptOnline(), $path, $model, true);
- $schema = self::parseLayoutRaw($raw);
- $score = self::layoutScore($schema);
- $best = ['raw' => $raw, 'schema' => $schema, 'model' => $model, 'score' => $score];
- if ($score >= 3) {
- return $best;
- }
- } catch (\Exception $e) {
- throw new Exception('线上识版失败:' . $e->getMessage());
- }
- throw new Exception('线上识版没有读出完整格子。请核对视觉模型名(需带视觉能力)。');
- }
- $models = [];
- if ($preferLocal && $preferredModel !== '') {
- $models[] = $preferredModel;
- }
- foreach ([$local->visionModel(), $local->visionModelComplex()] as $m) {
- if ($m !== '' && !in_array($m, $models, true)) {
- $models[] = $m;
- }
- }
- if (!$models) {
- if ($best['score'] >= 3) {
- return $best;
- }
- throw new Exception('未配置视觉模型。请在后台「大模型设置」填写线上视觉模型,或保留本地 Ollama 视觉模型。');
- }
- foreach ($models as $model) {
- $raw = $local->visionRaw(self::promptCsv(), $path, $model, false);
- $schema = self::parseLayoutRaw($raw);
- $score = self::layoutScore($schema);
- if ($score > $best['score']) {
- $best = ['raw' => $raw, 'schema' => $schema, 'model' => $model, 'score' => $score];
- }
- if ($score < 8) {
- $tiled = self::extractTiles($local, $path, $model);
- if ($tiled['score'] > $best['score']) {
- $best = $tiled;
- }
- }
- if ($best['score'] >= 12) {
- break;
- }
- }
- if ($best['score'] < 3) {
- if ($online->enabled()) {
- throw new Exception('线上识版没有读出完整格子。请核对 API Key、接口地址和视觉模型名(需带视觉能力,如 qwen-vl-max / gpt-4o)。');
- }
- throw new Exception('当前本地视觉模型读不出流程单格子。请在后台「大模型设置」启用线上视觉模型后再拆版。');
- }
- return $best;
- }
- /**
- * @param LlmClient $client
- * @param string $path
- * @param string $model
- * @return array
- */
- protected static function extractTiles(LlmClient $client, $path, $model)
- {
- $bands = self::splitImageBands($path, 3);
- if (!$bands) {
- return ['raw' => '', 'schema' => ['cells' => []], 'model' => $model, 'score' => 0];
- }
- $cells = [];
- $rawParts = [];
- foreach ($bands as $i => $band) {
- try {
- $raw = $client->visionRaw(self::promptTile($i + 1, count($bands)), $band['path'], $model, false);
- } catch (\Exception $e) {
- $raw = '';
- }
- $rawParts[] = '---band' . ($i + 1) . "---\n" . $raw;
- $part = self::parseLayoutRaw($raw);
- foreach (($part['cells'] ?? []) as $cell) {
- $y = (float)($cell['y'] ?? 0);
- $h = (float)($cell['h'] ?? 4);
- $cell['y'] = round(($band['y0'] + $y / 100 * $band['h']) / $band['fullH'] * 100, 2);
- $cell['h'] = max(1.2, round($h * $band['h'] / $band['fullH'], 2));
- $cells[] = $cell;
- }
- if (is_file($band['path'])) {
- @unlink($band['path']);
- }
- }
- $schema = ['name' => '', 'cells' => $cells];
- return [
- 'raw' => implode("\n", $rawParts),
- 'schema' => $schema,
- 'model' => $model,
- 'score' => self::layoutScore($schema),
- ];
- }
- /**
- * @param string $path
- * @param int $bands
- * @return array
- */
- protected static function splitImageBands($path, $bands = 4)
- {
- if (!is_file($path) || !function_exists('imagecreatefromstring')) {
- return [];
- }
- $bin = @file_get_contents($path);
- $im = $bin ? @imagecreatefromstring($bin) : false;
- if (!$im) {
- return [];
- }
- $w = imagesx($im);
- $h = imagesy($im);
- $overlap = (int)($h * 0.08);
- $bandH = (int)ceil($h / $bands);
- $out = [];
- for ($i = 0; $i < $bands; $i++) {
- $y = max(0, $i * $bandH - ($i ? $overlap : 0));
- $bh = min($h - $y, $bandH + $overlap);
- if ($bh < 20) {
- continue;
- }
- $dst = imagecreatetruecolor($w, $bh);
- imagecopy($dst, $im, 0, 0, 0, $y, $w, $bh);
- $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'psband_' . $i . '_' . uniqid() . '.jpg';
- imagejpeg($dst, $tmp, 86);
- imagedestroy($dst);
- $out[] = ['path' => $tmp, 'y0' => $y, 'h' => $bh, 'fullH' => $h, 'fullW' => $w];
- }
- imagedestroy($im);
- return $out;
- }
- /**
- * @param string $raw
- * @return array
- */
- protected static function parseLayoutRaw($raw)
- {
- $raw = trim((string)$raw);
- if ($raw !== '' && (strpos($raw, '{') !== false || strpos($raw, '[') !== false)) {
- try {
- $json = LlmClient::extractJson($raw);
- if (is_array($json) && (!empty($json['cells']) || !empty($json['elements']) || !empty($json['fields']))) {
- if (self::layoutScore($json) >= 3) {
- return $json;
- }
- }
- } catch (\Exception $e) {
- // CSV / 非 JSON 走下面的管道行解析
- }
- }
- $cells = [];
- foreach (preg_split("/\r\n|\n|\r/", $raw) as $line) {
- $line = trim($line);
- if ($line === '' || strpos($line, '|') === false) {
- continue;
- }
- $line = ltrim($line, "-* \t");
- $parts = array_map('trim', explode('|', $line));
- if (count($parts) < 6) {
- continue;
- }
- $head = mb_strtolower($parts[0]);
- if (in_array($head, ['text', '文字', '内容'], true)) {
- continue;
- }
- $x = self::toPct($parts[1]);
- $y = self::toPct($parts[2]);
- $w = self::toPct($parts[3]);
- $h = self::toPct($parts[4]);
- if ($w <= 0 || $h <= 0) {
- continue;
- }
- $role = mb_strtolower($parts[5]);
- $role = (strpos($role, 'fill') !== false || $role === '空' || $role === '填写') ? 'fill' : 'label';
- $key = isset($parts[6]) ? trim($parts[6]) : '';
- $color = isset($parts[7]) && preg_match('/^#?[0-9a-fA-F]{3,8}$/', $parts[7]) ? $parts[7] : '#000000';
- if ($color && $color[0] !== '#') {
- $color = '#' . $color;
- }
- $bg = isset($parts[8]) && preg_match('/^#?[0-9a-fA-F]{3,8}$/', $parts[8]) ? $parts[8] : '#ffffff';
- if ($bg && $bg[0] !== '#') {
- $bg = '#' . $bg;
- }
- $fontSize = isset($parts[9]) ? (int)$parts[9] : 13;
- $cells[] = [
- 'text' => $parts[0],
- 'x' => $x,
- 'y' => $y,
- 'w' => $w,
- 'h' => $h,
- 'role' => $role,
- 'key' => $role === 'fill' ? $key : '',
- 'fontSize' => $fontSize > 0 ? $fontSize : 13,
- 'color' => $color ?: '#000000',
- 'background' => $bg ?: '#ffffff',
- 'borders' => 'tblr',
- ];
- }
- return ['cells' => $cells];
- }
- /**
- * @param mixed $v
- * @return float
- */
- protected static function toPct($v)
- {
- $n = (float)preg_replace('/[^0-9.\-]/', '', (string)$v);
- if ($n < 0) {
- $n = 0;
- }
- if ($n > 100) {
- $n = 100;
- }
- return round($n, 2);
- }
- /**
- * @param array $schema
- * @return int
- */
- protected static function layoutScore(array $schema)
- {
- $items = [];
- foreach (['cells', 'elements', 'fields'] as $k) {
- if (!empty($schema[$k]) && is_array($schema[$k])) {
- $items = array_merge($items, $schema[$k]);
- }
- }
- $n = count($items);
- if ($n <= 2) {
- $texts = [];
- foreach ($items as $it) {
- $texts[] = trim((string)($it['text'] ?? $it['key'] ?? ''));
- }
- $joined = implode(' ', $texts);
- if (mb_strpos($joined, '产品名称') !== false && $n <= 2) {
- return 0;
- }
- }
- $uniq = [];
- foreach ($items as $it) {
- $t = trim((string)($it['text'] ?? $it['key'] ?? $it['label'] ?? ''));
- if ($t !== '') {
- $uniq[$t] = 1;
- } else {
- $uniq['fill:' . ($it['x'] ?? '') . ',' . ($it['y'] ?? '')] = 1;
- }
- }
- return count($uniq);
- }
- /**
- * @return string
- */
- protected static function promptCsv()
- {
- return <<<'PROMPT'
- 看这张印刷流程单照片,只找出空白填写格(还没写字的空格子)。不要解释,不要 JSON,不要抄示例。已印好的标题不要列出,它们会留在底图上。
- 每行格式:
- 旁标题|x|y|宽|高|fill|字段key|#111111|#transparent|13
- 规则:
- - x y 宽 高是空格相对整张图的百分比,0 到 100
- - 根据格子旁边已印好的标题猜字段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 ;对不上就留空
- - 至少列出图上所有空格,禁止只写一两行
- PROMPT;
- }
- /**
- * @param int $index
- * @param int $total
- * @return string
- */
- protected static function promptTile($index, $total)
- {
- return '这是流程单的第' . $index . '/' . $total . "横条局部。只列出空白填写格,不要抄示例,不要 JSON。每行:旁标题|x|y|宽|高|fill|字段key|#111111|#transparent|13\n"
- . "x y 宽 高是本张局部图的百分比 0-100。已印好的字不要列。";
- }
- /**
- * 线上视觉模型用 JSON。不要给可抄的两格示例。
- * @return string
- */
- protected static function promptOnline()
- {
- return <<<'PROMPT'
- 你是流程单套打定位引擎。只输出 JSON,不要解释、不要 Markdown。
- 打印时会把这张原图当底纸,只在空白格填工单/产品数据。因此只标出空白填写格,不要拆已印好的标题和线框。
- 根据空格旁边的印字猜测 key。x/y/w/h 是相对整张图的百分比 0~100。
- 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
- 把图上所有空格都列出来。
- {"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"}]}
- PROMPT;
- }
- /**
- * 识版会打很久,phpstudy 的 MySQL wait_timeout=120,必须先拉长会话再重连。
- */
- protected static function keepDbAlive()
- {
- try {
- Db::execute('SELECT 1');
- } catch (\Exception $e) {
- Db::connect([], true);
- }
- try {
- Db::execute('SET SESSION wait_timeout=3600, SESSION interactive_timeout=3600');
- } catch (\Exception $e) {
- // ignore
- }
- }
- /**
- * Windows 下 php-cgi 里 popen(start /B) 会等到子进程结束,Nginx 60 秒就 504。
- * 必须真正脱离当前 FastCGI 请求。
- * @param string $cwd
- * @param string $cmd
- * @param string $log
- * @param int $id
- */
- protected static function spawnWindows($cwd, $cmd, $log, $id)
- {
- $line = 'cd /d "' . $cwd . '" && ' . $cmd . ' >> "' . $log . '" 2>&1';
- if (class_exists('COM', false)) {
- try {
- $wsh = new \COM('WScript.Shell');
- $wsh->Run('cmd /c ' . $line, 0, false);
- return;
- } catch (\Exception $e) {
- // 走 start 兜底
- }
- }
- $bat = RUNTIME_PATH . 'ai_job_' . (int)$id . '.cmd';
- @file_put_contents($bat, "@echo off\r\n" . $line . "\r\n");
- if (is_file($bat)) {
- pclose(popen('start "ps-ai-' . (int)$id . '" /B "' . $bat . '"', 'r'));
- return;
- }
- pclose(popen('start "ps-ai-' . (int)$id . '" /B cmd /c "' . str_replace('"', '""', $line) . '"', 'r'));
- }
- /**
- * @return string
- */
- protected static function phpBinary()
- {
- $php = PHP_BINARY ?: '';
- if ($php && stripos($php, 'cgi') === false && stripos($php, 'fpm') === false && is_file($php)) {
- return $php;
- }
- $dir = $php ? dirname($php) : '';
- $candidates = [];
- if ($dir) {
- $candidates[] = $dir . DIRECTORY_SEPARATOR . 'php.exe';
- $candidates[] = $dir . DIRECTORY_SEPARATOR . 'php';
- }
- $candidates[] = 'D:\\phpstudy_pro\\Extensions\\php\\php7.4.3nts\\php.exe';
- foreach ($candidates as $candidate) {
- if ($candidate && is_file($candidate)) {
- return $candidate;
- }
- }
- return 'php';
- }
- }
|