diff --git a/bbit_ai/app/agent/licenseImageAgent.py b/bbit_ai/app/agent/licenseImageAgent.py new file mode 100644 index 0000000..22902b2 --- /dev/null +++ b/bbit_ai/app/agent/licenseImageAgent.py @@ -0,0 +1,152 @@ +from typing import TypedDict + +from langchain_core.messages import HumanMessage +from langgraph.graph import StateGraph, END + +from config.llm import llmVision + + +# -------- 定义状态 -------- +class State(TypedDict): + image_url: str # 证件照 + type: int # 证件类别 + content: str # 最终内容 + + +def analyze_card(state: State, prompt_text: str): + messages = [ + HumanMessage( + content=[ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": {"url": state["image_url"]}}, + ] + ) + ] + return llmVision.invoke(messages).content + + +# -------- 定义节点 -------- +def decide_source(state: State, max_retry=3): + choice = analyze_card( + state, + """ + 请根据图片内容判断其中的证件类型,并输出对应代号。 + 代号说明: + -1: 并非证件 + 0:身份证 + 1:银行卡 + + 只输出代号数字,不要输出任何文字、标点或解释。 + 你的输出是: + """, + ) + print("图片类型是", choice) + try: + choice = int(choice) + state["type"] = choice + if choice == -1: + state["content"] = '{"result": "暂不支持此证件"}' + except ValueError: + state["type"] = -1 + state["content"] = '{"result": "暂不支持此证件"}' + return state + + +def idcard(state: State): + state["content"] = analyze_card( + state, + """ + 你是一个 OCR 信息提取专家,请从图片中识别出身份证的全部可见信息,并输出严格的 JSON 格式。 + 要求: + 1. 无论是正面还是反面,都要识别所有字段。 + 2. 如果某个字段无法辨认,请将值设为 null。 + 3. 只输出 JSON,不要输出任何解释或多余内容。 + 4. 严格保持 JSON 格式,键名统一为英文。 + 5. "side" 字段为身份证方向,填写:人像面 或 国徽面。 + + JSON 示例格式: + { + "side": "国徽面", + "name": "持证人姓名", + "gender": "性别", + "ethnicity": "民族", + "id_number": "身份证号", + "birth_date": "出生日期", + "address": "住址", + "issuing_authority": "签发机关", + "valid_period_start": "有效期开始日期", + "valid_period_end": "有效期结束日期", + "notes": "其他可见文字或备注" + } + + 请确保输出的 JSON 可以被严格解析。 +""", + ) + return state + + +def bankcard(state: State): + state["content"] = analyze_card( + state, + """ + 你是一个 OCR 信息提取专家,请从图片中识别出银行卡上的全部可见信息,并输出严格的 JSON 格式。 + 要求: + 1. 识别卡号、发卡行、持卡人、有效期等关键信息。 + 2. 如果某个字段无法辨认,请将值设为 null。 + 3. 只输出 JSON,不要输出任何解释或附加内容。 + 4. 严格保持 JSON 格式,键名统一为英文。 + + JSON 示例格式: + { + "bank_name": "发卡行名称", + "card_number": "卡号", + "card_holder": "持卡人姓名", + "expiry_date": "有效期", + "card_type": "卡种类型(如借记卡/信用卡/Visa/MasterCard)", + "issuer_country": "发卡国家", + "notes": "其他可见文字或符号" + } + + 请确保输出的 JSON 可以被严格解析。 +""", + ) + return state + + +# ------------------------------------------------------------------------ 构建有向图 -------- +workflow = StateGraph(State) +workflow.add_node("decide", decide_source) +workflow.add_node("idcard", idcard) +workflow.add_node("bankcard", bankcard) +workflow.set_entry_point("decide") +# 条件边:根据 path 决定走向 +workflow.add_conditional_edges( + "decide", + lambda state: state["type"], + { + -1: END, + 0: "idcard", + 1: "bankcard", + }, +) +workflow.add_edge("idcard", END) +workflow.add_edge("bankcard", END) +graph = workflow.compile() + +import re + + +# 执行函数 +def get_license_response(image_url: str): + final_state = graph.invoke( + { + "image_url": image_url, + } + ) + # 去掉 ```json 和 ``` 包裹 + final_state["content"] = final_state["content"] + final_state["content"] = re.sub( + r"^```json\s*|\s*```$", "", final_state["content"].strip() + ) + print("最终识别内容:", final_state["content"]) + return final_state diff --git a/bbit_ai/app/agent/ticketAgent.py b/bbit_ai/app/agent/ticketAgent.py deleted file mode 100644 index 1e9e992..0000000 --- a/bbit_ai/app/agent/ticketAgent.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Optional -from pydantic import BaseModel, Field -from langchain_core.messages import HumanMessage - -from config.llm import * -from langchain.prompts import PromptTemplate - -from langchain.prompts import PromptTemplate -from langchain.schema import HumanMessage -import os -import base64 -from langchain.prompts import PromptTemplate -from langchain_openai import ChatOpenAI -from langchain_core.output_parsers import JsonOutputParser -from langchain.schema import HumanMessage -from pydantic import BaseModel, Field - -from pydantic import BaseModel, Field -import json - -import re -import json -import requests -import cv2 -import numpy as np -import requests - -class CocoonSample(BaseModel): - moisture_content: float = Field( - ..., - description="茧的含水量,单位为百分比(%),浮点数" - ) - cocoon_weight: float = Field( - ..., - description="下足茧的重量,单位为克,可带小数" - ) - defective_pupa_count: int = Field( - ..., - description="非好蛹粒数,即不合格蛹的数量,整数" - ) - fresh_shell_weight: float = Field( - ..., - description="鲜壳重量,单位为克,可带小数" - ) - sample_count: int = Field( - ..., - description="小样粒数,用于检测的茧粒数,整数" - ) - net_weight_total: float = Field( - ..., - description="所有样品的净重合计,单位为克,浮点数" - ) - evaluator: Optional[str] = Field( - None, - description="仪评人姓名,可能为空" - ) - reviewer: Optional[str] = Field( - None, - description="复核人员姓名,可能为空" - ) diff --git a/bbit_ai/app/db/postgres.py b/bbit_ai/app/db/postgres.py index d4c2b6e..bd543b1 100644 --- a/bbit_ai/app/db/postgres.py +++ b/bbit_ai/app/db/postgres.py @@ -1,4 +1,3 @@ -import json from typing import List, Dict from langchain_postgres import PostgresChatMessageHistory @@ -559,3 +558,80 @@ def insert_ticket_image( new_id = cursor.fetchone()[0] conn.commit() return new_id + + +import json + +# ————————————————————————————————————————————————————证件照片识别——————————————————————————————— + + +def insert_license_image( + created_by, file_name, resolution, size, name, oss, type, content +): + with pg_pool.getConn() as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + INSERT INTO license_images ( + created_by, created_at, file_name, resolution, size, name, oss, + type, content + ) + VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s ) + RETURNING id + """, + (created_by, file_name, resolution, size, name, oss, type, content), + ) + new_id = cursor.fetchone()[0] + conn.commit() + return new_id + + +def get_license_image_list(user_id, page=1, page_size=10): + """ + 获取用户已分析图片列表,带分页 + """ + offset = (page - 1) * page_size + + with pg_pool.getConn() as conn: + with conn.cursor() as cursor: + # 1️⃣ 查询总条数 + cursor.execute( + """ + SELECT COUNT(*) + FROM license_images + WHERE created_by = %s + """, + (user_id,), + ) + total = cursor.fetchone()[0] + + # 2️⃣ 查询当前页数据 + cursor.execute( + """ + SELECT created_at, file_name, resolution, size, name, oss, id, type, content + FROM license_images + WHERE created_by = %s + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (user_id, page_size, offset), + ) + rows = cursor.fetchall() + + result = [] + for row in rows: + result.append( + { + "created_at": MyUtils.format_datetime(row[0]), + "file_name": row[1], + "resolution": row[2], + "size": round(row[3] / 1024, 2), + "name": row[4], + "oss_url": get_temp_url("image-license", row[5]), + "id": row[6], + "type": row[7], + "content": row[8], + } + ) + + return total, result diff --git a/bbit_ai/app/routers/Public.py b/bbit_ai/app/routers/Public.py index b0233fe..2c6851b 100644 --- a/bbit_ai/app/routers/Public.py +++ b/bbit_ai/app/routers/Public.py @@ -4,8 +4,9 @@ from fastapi import APIRouter from config.app import F8_SERVER_USER_ID from models.BaseResponse import BaseResponse +from models.F8ImageRequest import F8ImageRequest from models.F8ImageRequestV2 import F8ImageRequestV2 -from service.vision import process_ticket_image +from service.vision import process_ticket_image, process_license_image from utils import MyUtils publicRouter = APIRouter() @@ -30,3 +31,27 @@ async def cocoonTicket(data: F8ImageRequestV2): return BaseResponse(data=json_data) except Exception as e: return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None) + + +import json + + +@publicRouter.post("/recognize-license") +async def cocoon_license(data: F8ImageRequest): + input_data = data.image + if "," in input_data: + input_data = input_data.split(",")[1] + try: + img_bytes = base64.b64decode(input_data) + json_data = await MyUtils.async_task( + process_license_image, + img_bytes, + f"{data.title}.jpg", + data.title, + F8_SERVER_USER_ID, + ) + data = json.loads(json_data.get("content")) + data["type"] = json_data.get("type") + return BaseResponse(data=data) + except Exception as e: + return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None) diff --git a/bbit_ai/app/routers/Vision.py b/bbit_ai/app/routers/Vision.py index 3ec900e..70a167c 100644 --- a/bbit_ai/app/routers/Vision.py +++ b/bbit_ai/app/routers/Vision.py @@ -1,13 +1,13 @@ from uuid import UUID -from fastapi import APIRouter, File, Form, Depends +from fastapi import APIRouter, File, Form, Depends, Query import db.postgres as pg from config.security import get_user_id_from_token from llm.ticketLLM import * from models.BaseResponse import BaseResponse from models.ImageRequest import ImageRequest -from service.vision import process_ticket_image +from service.vision import process_ticket_image, process_license_image from utils import MyUtils visionRouter = APIRouter() @@ -51,6 +51,25 @@ def cocoonTicket(user_id: UUID = Depends(get_user_id_from_token)): return BaseResponse(data=pg.get_ticket_image_list(user_id)) +@visionRouter.post("/createTicketImageTaskV2") +async def createTicketImageTask( + file: UploadFile = File(...), + projectName: str = Form(...), + user_id: UUID = Depends(get_user_id_from_token), +): + if not user_id: + return {"error": "userId is required"} + try: + contents = await file.read() + await MyUtils.async_task( + process_ticket_image, 2, True, contents, file.filename, projectName, user_id + ) + return BaseResponse(data=None) + except Exception as e: + print(str(e)) + return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None) + + @visionRouter.post("/createTicketImageTaskV2") async def createTicketImageTask( file: UploadFile = File(...), @@ -66,5 +85,39 @@ async def createTicketImageTask( ) return BaseResponse(data=json_data) except Exception as e: - print(str(e)) return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None) + + +@visionRouter.post("/createLicenseImageTask") +async def createLicenseImageTask( + file: UploadFile = File(...), + projectName: str = Form(...), + user_id: UUID = Depends(get_user_id_from_token), +): + if not user_id: + return {"error": "userId is required"} + try: + contents = await file.read() + await MyUtils.async_task( + process_license_image, contents, file.filename, projectName, user_id + ) + return BaseResponse(data=None) + except Exception as e: + return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None) + + +@visionRouter.get("/getLicenseImageList") +def cocoonLicense( + user_id: UUID = Depends(get_user_id_from_token), + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100), +): + if not user_id: + return {"error": "userId is required"} + total, items = pg.get_license_image_list(user_id, page=page, page_size=page_size) + return BaseResponse( + data={ + "total": total, + "items": items, + } + ) diff --git a/bbit_ai/app/service/vision.py b/bbit_ai/app/service/vision.py index 753b9ad..345873d 100644 --- a/bbit_ai/app/service/vision.py +++ b/bbit_ai/app/service/vision.py @@ -3,6 +3,7 @@ from uuid import UUID import config.minIO as minIO import db.postgres as pg +from agent.licenseImageAgent import get_license_response from config.minIO import minio_client from llm.ticketLLM import * from llm.ticketLLMv2 import get_ticket_response_v2 @@ -65,3 +66,43 @@ def process_ticket_image( ) return json_data + + +def process_license_image( + img_bytes=None, + file_name: str = None, + project_name: str = None, + user_id: UUID = None, +): + # 上传到 OSS,使用 UUID 做对象名 + if img_bytes is None: + img_bytes = [] + object_name = str(uuid.uuid4()) + file_bytes = BytesIO(img_bytes) + bucket_name = "image-license" + if not minio_client.bucket_exists(bucket_name): + minio_client.make_bucket(bucket_name) + + minIO.push_file(bucket_name, object_name, file_bytes, img_bytes, "image/jpeg") + oss_url = minIO.get_temp_url(bucket_name, object_name) + + # 调用分析方法获取 JSON + json_data = get_license_response(oss_url) + # 获取图片分辨率和大小 + img = Image.open(BytesIO(img_bytes)) + resolution = f"{img.width}x{img.height}" + size_kb = round(len(img_bytes) / 1024, 2) + + # 插入数据库 + pg.insert_license_image( + created_by=user_id, + file_name=file_name, + resolution=resolution, + size=size_kb, + name=project_name if project_name else object_name[:8], + oss=object_name, + type=json_data.get("type"), + content=json_data.get("content"), + ) + + return json_data diff --git a/readme.md b/readme.md index b32e3e2..a7d59e6 100644 --- a/readme.md +++ b/readme.md @@ -70,7 +70,7 @@ | | 9100 | | ce_node_exporter | Prometheus | Prometheus监控主机的工具 | | | | | | | | | | | | | | | -| **13011** | 13011 | 13011 | Harbor | Harbor | Harbor,admin:bbit | +| **13011** | 13011 | 13011 | Harbor | Harbor | Harbor,admin:Bbit000000 | | 8088 | | | | | 建议后续关闭,原Android远程框架,现已由网关控制 | | 8089 | | | | | 建议后续关闭,原Ktor后端服务,现已由网关控制 | @@ -176,7 +176,7 @@ docker image prune -f 4. (2x客户端)登录 ``` - docker login http://s1.ronsunny.cn:13011 + docker login ai.ronsunny.cn:13011 ``` - Prometheus @@ -226,6 +226,14 @@ docker image prune -f ## 五、其他 +docker 权限 + +sudo groupadd docker + +sudo usermod -aG docker kaijihar + +newgrp docker + ### 旧部署 后端 diff --git a/vue/apps/web-antd/src/api/cv/index.ts b/vue/apps/web-antd/src/api/cv/index.ts index 442809a..effdfc5 100644 --- a/vue/apps/web-antd/src/api/cv/index.ts +++ b/vue/apps/web-antd/src/api/cv/index.ts @@ -1,3 +1,4 @@ export * from './iva'; +export * from './license'; export * from './sca'; export * from './ticket'; diff --git a/vue/apps/web-antd/src/api/cv/license.ts b/vue/apps/web-antd/src/api/cv/license.ts new file mode 100644 index 0000000..5fdcb27 --- /dev/null +++ b/vue/apps/web-antd/src/api/cv/license.ts @@ -0,0 +1,21 @@ +import { pyRequestClient } from '#/api/request'; + +/** + * 获取已分析的图片列表 + */ +export async function refreshLicenseImageList(page = 1, pageSize = 10) { + return pyRequestClient.get('/llm/getLicenseImageList', { + params: { page, page_size: pageSize }, + }); +} + +/** + * 上传图片分析任务 + */ +export async function createLicenseImageTask(formData: FormData) { + return pyRequestClient.post('/llm/createLicenseImageTask', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); +} diff --git a/vue/apps/web-antd/src/router/routes/modules/cv.ts b/vue/apps/web-antd/src/router/routes/modules/cv.ts index 6c1fceb..16e4116 100644 --- a/vue/apps/web-antd/src/router/routes/modules/cv.ts +++ b/vue/apps/web-antd/src/router/routes/modules/cv.ts @@ -7,7 +7,7 @@ const routes: RouteRecordRaw[] = [ { meta: { icon: 'ic:round-remove-red-eye', - authority: ['iva', 'sca', 'ysa', 'ticket'], + authority: ['iva', 'sca', 'ysa', 'ticket', 'license'], keepAlive: true, order: 2, title: $t('计算机视觉'), @@ -59,6 +59,17 @@ const routes: RouteRecordRaw[] = [ }, component: () => import('#/views/cv/ticket/index.vue'), }, + { + name: 'LICENSE', + path: '/cv/license', + meta: { + authority: ['license'], + icon: 'mdi:certificate', + title: $t('证件照片分析'), + keepAlive: true, + }, + component: () => import('#/views/cv/license/index.vue'), + }, { name: 'CVAT', path: '/cv/cvat', diff --git a/vue/apps/web-antd/src/router/routes/modules/dashboard.ts b/vue/apps/web-antd/src/router/routes/modules/dashboard.ts index b6d4a1d..1ec3ed1 100644 --- a/vue/apps/web-antd/src/router/routes/modules/dashboard.ts +++ b/vue/apps/web-antd/src/router/routes/modules/dashboard.ts @@ -38,7 +38,7 @@ const routes: RouteRecordRaw[] = [ component: IFrameView, meta: { icon: 'mdi:wall-fire', - iframeSrc: 'http://ai.ronsunny.cn:13010/', + iframeSrc: 'http://s1.ronsunny.cn:13010/', keepAlive: false, title: 'RAG Flow', }, diff --git a/vue/apps/web-antd/src/views/cv/license/index.vue b/vue/apps/web-antd/src/views/cv/license/index.vue new file mode 100644 index 0000000..da37523 --- /dev/null +++ b/vue/apps/web-antd/src/views/cv/license/index.vue @@ -0,0 +1,312 @@ + + + diff --git a/vue/apps/web-antd/src/views/cv/ticket/index.vue b/vue/apps/web-antd/src/views/cv/ticket/index.vue index 452fda9..96b45fd 100644 --- a/vue/apps/web-antd/src/views/cv/ticket/index.vue +++ b/vue/apps/web-antd/src/views/cv/ticket/index.vue @@ -94,15 +94,14 @@ async function uploadFile() { formData.append('file', selectedFile.value); formData.append('projectName', projectName.value); await api.createTicketImageTask(formData); - - // 接口完成后再触发事件 - message.success('分析任务完成'); - loadList(); - // 清空表单 selectedFile.value = null; projectName.value = ''; fileName.value = ''; + + // 接口完成后再触发事件 + message.success('分析任务完成'); + loadList(); } catch { message.error('上传失败'); } diff --git a/vue/apps/web-antd/src/views/dashboard/workspace/index.vue b/vue/apps/web-antd/src/views/dashboard/workspace/index.vue index ac99097..084be65 100644 --- a/vue/apps/web-antd/src/views/dashboard/workspace/index.vue +++ b/vue/apps/web-antd/src/views/dashboard/workspace/index.vue @@ -43,6 +43,13 @@ const cv: WorkbenchQuickNavItem[] = [ title: '仪评指标联分析', url: '/cv/ticket', }, + { + color: '#3fb27f', + authority: ['license'], + icon: 'mdi:certificate', + title: '证件照片分析', + url: '/cv/license', + }, { color: '#3fb27f', icon: 'ion:bar-chart-outline',