Showing
33 changed files
with
2313 additions
and
2313 deletions
| 1 | -# Python | ||
| 2 | -__pycache__/ | ||
| 3 | -*.py[cod] | ||
| 4 | -*$py.class | ||
| 5 | -*.so | ||
| 6 | -.Python | ||
| 7 | -*.egg-info/ | ||
| 8 | -dist/ | ||
| 9 | -build/ | ||
| 10 | - | ||
| 11 | -# Virtual environments | ||
| 12 | -.venv/ | ||
| 13 | -venv/ | ||
| 14 | - | ||
| 15 | -# IDE | ||
| 16 | -.idea/ | ||
| 17 | -.vscode/ | ||
| 18 | -*.swp | ||
| 19 | -*.swo | ||
| 20 | - | ||
| 21 | -# Git | ||
| 22 | -.git/ | ||
| 23 | -.gitignore | ||
| 24 | - | ||
| 25 | -# Docker | ||
| 26 | -Dockerfile | ||
| 27 | -.dockerignore | ||
| 28 | - | ||
| 29 | -# Logs | ||
| 30 | -*.log | ||
| 31 | - | ||
| 32 | -# Local configs | ||
| 33 | -cfg/config.local.yaml | 1 | +# Python |
| 2 | +__pycache__/ | ||
| 3 | +*.py[cod] | ||
| 4 | +*$py.class | ||
| 5 | +*.so | ||
| 6 | +.Python | ||
| 7 | +*.egg-info/ | ||
| 8 | +dist/ | ||
| 9 | +build/ | ||
| 10 | + | ||
| 11 | +# Virtual environments | ||
| 12 | +.venv/ | ||
| 13 | +venv/ | ||
| 14 | + | ||
| 15 | +# IDE | ||
| 16 | +.idea/ | ||
| 17 | +.vscode/ | ||
| 18 | +*.swp | ||
| 19 | +*.swo | ||
| 20 | + | ||
| 21 | +# Git | ||
| 22 | +.git/ | ||
| 23 | +.gitignore | ||
| 24 | + | ||
| 25 | +# Docker | ||
| 26 | +Dockerfile | ||
| 27 | +.dockerignore | ||
| 28 | + | ||
| 29 | +# Logs | ||
| 30 | +*.log | ||
| 31 | + | ||
| 32 | +# Local configs | ||
| 33 | +cfg/config.local.yaml |
| 1 | -/live-stream-ai-task-workline/modules/wtt_highlight/model_weights/* | ||
| 2 | -*mov | ||
| 3 | -/.externalToolBuilders | ||
| 4 | -/.settings | ||
| 5 | -.svn | ||
| 6 | -/.project | ||
| 7 | -/target | ||
| 8 | -./target | ||
| 9 | -/.classpath | ||
| 10 | -.idea/ | ||
| 11 | -*.iml | ||
| 12 | -*.iws | ||
| 13 | -*.ipr | ||
| 14 | -*.ids | ||
| 15 | -*.orig | ||
| 16 | -classes/ | ||
| 17 | -/bin/ | ||
| 18 | -*/.classpath | ||
| 19 | -*/.project | ||
| 20 | -*/.settings/* | ||
| 21 | -###################### | ||
| 22 | -# Project Specific | ||
| 23 | -###################### | ||
| 24 | -/target/www/** | ||
| 25 | -/src/test/javascript/coverage/ | ||
| 26 | -/src/test/javascript/PhantomJS*/ | ||
| 27 | - | ||
| 28 | -###################### | ||
| 29 | -# Node | ||
| 30 | -###################### | ||
| 31 | -/node/ | ||
| 32 | -node_tmp/ | ||
| 33 | -node_modules/ | ||
| 34 | -npm-debug.log.* | ||
| 35 | -/.awcache/* | ||
| 36 | - | ||
| 37 | -###################### | ||
| 38 | -# SASS | ||
| 39 | -###################### | ||
| 40 | -.sass-cache/ | ||
| 41 | - | ||
| 42 | -###################### | ||
| 43 | -# Eclipse | ||
| 44 | -###################### | ||
| 45 | -*.pydevproject | ||
| 46 | -.project | ||
| 47 | -.metadata | ||
| 48 | -tmp/ | ||
| 49 | -tmp/**/* | ||
| 50 | -*.tmp | ||
| 51 | -*.bak | ||
| 52 | -*.swp | ||
| 53 | -*~.nib | ||
| 54 | -local.properties | ||
| 55 | -.classpath | ||
| 56 | -.settings/ | ||
| 57 | -.loadpath | ||
| 58 | -.factorypath | ||
| 59 | -/src/main/resources/rebel.xml | ||
| 60 | - | ||
| 61 | -# External tool builders | ||
| 62 | -.externalToolBuilders/** | ||
| 63 | - | ||
| 64 | -# Locally stored "Eclipse launch configurations" | ||
| 65 | -*.launch | ||
| 66 | - | ||
| 67 | -# CDT-specific | ||
| 68 | -.cproject | ||
| 69 | - | ||
| 70 | -# PDT-specific | ||
| 71 | -.buildpath | ||
| 72 | - | ||
| 73 | -###################### | ||
| 74 | -# Intellij | ||
| 75 | -###################### | ||
| 76 | -.idea/ | ||
| 77 | -*.iml | ||
| 78 | -*.iws | ||
| 79 | -*.ipr | ||
| 80 | -*.ids | ||
| 81 | -*.orig | ||
| 82 | -classes/ | ||
| 83 | - | ||
| 84 | -###################### | ||
| 85 | -# Visual Studio Code | ||
| 86 | -###################### | ||
| 87 | -.vscode/ | ||
| 88 | - | ||
| 89 | -###################### | ||
| 90 | -# Maven | ||
| 91 | -###################### | ||
| 92 | -/log/ | ||
| 93 | -/target/ | ||
| 94 | - | ||
| 95 | -###################### | ||
| 96 | -# Gradle | ||
| 97 | -###################### | ||
| 98 | -.gradle/ | ||
| 99 | -/build/ | ||
| 100 | - | ||
| 101 | -###################### | ||
| 102 | -# Package Files | ||
| 103 | -###################### | ||
| 104 | -*.jar | ||
| 105 | -*.war | ||
| 106 | -*.ear | ||
| 107 | -*.db | ||
| 108 | - | ||
| 109 | -###################### | ||
| 110 | -# Windows | ||
| 111 | -###################### | ||
| 112 | -# Windows image file caches | ||
| 113 | -Thumbs.db | ||
| 114 | - | ||
| 115 | -# Folder com.migu.oes.power.config file | ||
| 116 | -Desktop.ini | ||
| 117 | - | ||
| 118 | -###################### | ||
| 119 | -# Mac OSX | ||
| 120 | -###################### | ||
| 121 | -.DS_Store | ||
| 122 | -.svn | ||
| 123 | - | ||
| 124 | -# Thumbnails | ||
| 125 | -._* | ||
| 126 | - | ||
| 127 | -# Files that might appear on external disk | ||
| 128 | -.Spotlight-V100 | ||
| 129 | -.Trashes | ||
| 130 | - | ||
| 131 | -###################### | ||
| 132 | -# Directories | ||
| 133 | -###################### | ||
| 134 | -/bin/ | ||
| 135 | -/deploy/ | ||
| 136 | - | ||
| 137 | -###################### | ||
| 138 | -# Logs | ||
| 139 | -###################### | ||
| 140 | -*.log* | ||
| 141 | - | ||
| 142 | -###################### | ||
| 143 | -# Others | ||
| 144 | -###################### | ||
| 145 | -*.class | ||
| 146 | -*.*~ | ||
| 147 | -*~ | ||
| 148 | -.merge_file* | ||
| 149 | - | ||
| 150 | -###################### | ||
| 151 | -# Gradle Wrapper | ||
| 152 | -###################### | ||
| 153 | -!gradle/wrapper/gradle-wrapper.jar | ||
| 154 | - | ||
| 155 | -###################### | ||
| 156 | -# Maven Wrapper | ||
| 157 | -###################### | ||
| 158 | -!.mvn/wrapper/maven-wrapper.jar | ||
| 159 | - | ||
| 160 | -###################### | ||
| 161 | -# ESLint | ||
| 162 | -###################### | ||
| 163 | -.eslintcache | ||
| 164 | -*.lst | ||
| 165 | -oes-nryy-vo/target/maven-archiver/pom.properties | ||
| 166 | -oes-nryy-dataservice/target/maven-archiver/pom.properties | ||
| 167 | -oes-nryy-base/target/maven-archiver/pom.properties | ||
| 168 | -oes-nryy-copyright/bin/.gitkeep | ||
| 169 | -oes-nryy-copyright/bin/cbak | ||
| 170 | -*.original | ||
| 171 | -*.gz | ||
| 172 | -oes-nryy-tag/target/oes-nryy-tag-1.0.0/label/bin/service.sh | ||
| 173 | -oes-nryy-tag/target/maven-archiver/pom.properties | ||
| 174 | -oes-nryy-data-trans/target/maven-archiver/pom.properties | ||
| 175 | -oes-nryy-tag/target/oes-nryy-tag-1.0.0/label/bin/cbak | ||
| 176 | - | ||
| 177 | - | ||
| 178 | -###################### | ||
| 179 | -# pp-scene-det | ||
| 180 | -###################### | ||
| 181 | -__pycache__/ | ||
| 182 | - | ||
| 183 | -###################### | ||
| 184 | -# live-stream-ai-task-workline | ||
| 185 | -###################### | ||
| 186 | -modules/basketball/weights | ||
| 187 | -modules/singer/weights | ||
| 188 | -weights/ | ||
| 189 | -weights1/ | ||
| 190 | -files/ | ||
| 191 | -*.log | ||
| 192 | -*.log.* | ||
| 193 | -cache_data/ | ||
| 194 | -data/ | ||
| 195 | -test/ | ||
| 196 | -tests/ | ||
| 197 | -# Media files | ||
| 198 | -*.jpg | ||
| 199 | -*.jpeg | ||
| 200 | -*.png | ||
| 201 | -*.gif | ||
| 202 | -*.mp4 | ||
| 203 | -*.avi | ||
| 204 | -*.mov | ||
| 205 | -*.mkv | ||
| 206 | - | ||
| 207 | -# OS | ||
| 208 | -.DS_Store | ||
| 209 | -Thumbs.db | ||
| 210 | -logs/ | 1 | +/live-stream-ai-task-workline/modules/wtt_highlight/model_weights/* |
| 2 | +*mov | ||
| 3 | +/.externalToolBuilders | ||
| 4 | +/.settings | ||
| 5 | +.svn | ||
| 6 | +/.project | ||
| 7 | +/target | ||
| 8 | +./target | ||
| 9 | +/.classpath | ||
| 10 | +.idea/ | ||
| 11 | +*.iml | ||
| 12 | +*.iws | ||
| 13 | +*.ipr | ||
| 14 | +*.ids | ||
| 15 | +*.orig | ||
| 16 | +classes/ | ||
| 17 | +/bin/ | ||
| 18 | +*/.classpath | ||
| 19 | +*/.project | ||
| 20 | +*/.settings/* | ||
| 21 | +###################### | ||
| 22 | +# Project Specific | ||
| 23 | +###################### | ||
| 24 | +/target/www/** | ||
| 25 | +/src/test/javascript/coverage/ | ||
| 26 | +/src/test/javascript/PhantomJS*/ | ||
| 27 | + | ||
| 28 | +###################### | ||
| 29 | +# Node | ||
| 30 | +###################### | ||
| 31 | +/node/ | ||
| 32 | +node_tmp/ | ||
| 33 | +node_modules/ | ||
| 34 | +npm-debug.log.* | ||
| 35 | +/.awcache/* | ||
| 36 | + | ||
| 37 | +###################### | ||
| 38 | +# SASS | ||
| 39 | +###################### | ||
| 40 | +.sass-cache/ | ||
| 41 | + | ||
| 42 | +###################### | ||
| 43 | +# Eclipse | ||
| 44 | +###################### | ||
| 45 | +*.pydevproject | ||
| 46 | +.project | ||
| 47 | +.metadata | ||
| 48 | +tmp/ | ||
| 49 | +tmp/**/* | ||
| 50 | +*.tmp | ||
| 51 | +*.bak | ||
| 52 | +*.swp | ||
| 53 | +*~.nib | ||
| 54 | +local.properties | ||
| 55 | +.classpath | ||
| 56 | +.settings/ | ||
| 57 | +.loadpath | ||
| 58 | +.factorypath | ||
| 59 | +/src/main/resources/rebel.xml | ||
| 60 | + | ||
| 61 | +# External tool builders | ||
| 62 | +.externalToolBuilders/** | ||
| 63 | + | ||
| 64 | +# Locally stored "Eclipse launch configurations" | ||
| 65 | +*.launch | ||
| 66 | + | ||
| 67 | +# CDT-specific | ||
| 68 | +.cproject | ||
| 69 | + | ||
| 70 | +# PDT-specific | ||
| 71 | +.buildpath | ||
| 72 | + | ||
| 73 | +###################### | ||
| 74 | +# Intellij | ||
| 75 | +###################### | ||
| 76 | +.idea/ | ||
| 77 | +*.iml | ||
| 78 | +*.iws | ||
| 79 | +*.ipr | ||
| 80 | +*.ids | ||
| 81 | +*.orig | ||
| 82 | +classes/ | ||
| 83 | + | ||
| 84 | +###################### | ||
| 85 | +# Visual Studio Code | ||
| 86 | +###################### | ||
| 87 | +.vscode/ | ||
| 88 | + | ||
| 89 | +###################### | ||
| 90 | +# Maven | ||
| 91 | +###################### | ||
| 92 | +/log/ | ||
| 93 | +/target/ | ||
| 94 | + | ||
| 95 | +###################### | ||
| 96 | +# Gradle | ||
| 97 | +###################### | ||
| 98 | +.gradle/ | ||
| 99 | +/build/ | ||
| 100 | + | ||
| 101 | +###################### | ||
| 102 | +# Package Files | ||
| 103 | +###################### | ||
| 104 | +*.jar | ||
| 105 | +*.war | ||
| 106 | +*.ear | ||
| 107 | +*.db | ||
| 108 | + | ||
| 109 | +###################### | ||
| 110 | +# Windows | ||
| 111 | +###################### | ||
| 112 | +# Windows image file caches | ||
| 113 | +Thumbs.db | ||
| 114 | + | ||
| 115 | +# Folder com.migu.oes.power.config file | ||
| 116 | +Desktop.ini | ||
| 117 | + | ||
| 118 | +###################### | ||
| 119 | +# Mac OSX | ||
| 120 | +###################### | ||
| 121 | +.DS_Store | ||
| 122 | +.svn | ||
| 123 | + | ||
| 124 | +# Thumbnails | ||
| 125 | +._* | ||
| 126 | + | ||
| 127 | +# Files that might appear on external disk | ||
| 128 | +.Spotlight-V100 | ||
| 129 | +.Trashes | ||
| 130 | + | ||
| 131 | +###################### | ||
| 132 | +# Directories | ||
| 133 | +###################### | ||
| 134 | +/bin/ | ||
| 135 | +/deploy/ | ||
| 136 | + | ||
| 137 | +###################### | ||
| 138 | +# Logs | ||
| 139 | +###################### | ||
| 140 | +*.log* | ||
| 141 | + | ||
| 142 | +###################### | ||
| 143 | +# Others | ||
| 144 | +###################### | ||
| 145 | +*.class | ||
| 146 | +*.*~ | ||
| 147 | +*~ | ||
| 148 | +.merge_file* | ||
| 149 | + | ||
| 150 | +###################### | ||
| 151 | +# Gradle Wrapper | ||
| 152 | +###################### | ||
| 153 | +!gradle/wrapper/gradle-wrapper.jar | ||
| 154 | + | ||
| 155 | +###################### | ||
| 156 | +# Maven Wrapper | ||
| 157 | +###################### | ||
| 158 | +!.mvn/wrapper/maven-wrapper.jar | ||
| 159 | + | ||
| 160 | +###################### | ||
| 161 | +# ESLint | ||
| 162 | +###################### | ||
| 163 | +.eslintcache | ||
| 164 | +*.lst | ||
| 165 | +oes-nryy-vo/target/maven-archiver/pom.properties | ||
| 166 | +oes-nryy-dataservice/target/maven-archiver/pom.properties | ||
| 167 | +oes-nryy-base/target/maven-archiver/pom.properties | ||
| 168 | +oes-nryy-copyright/bin/.gitkeep | ||
| 169 | +oes-nryy-copyright/bin/cbak | ||
| 170 | +*.original | ||
| 171 | +*.gz | ||
| 172 | +oes-nryy-tag/target/oes-nryy-tag-1.0.0/label/bin/service.sh | ||
| 173 | +oes-nryy-tag/target/maven-archiver/pom.properties | ||
| 174 | +oes-nryy-data-trans/target/maven-archiver/pom.properties | ||
| 175 | +oes-nryy-tag/target/oes-nryy-tag-1.0.0/label/bin/cbak | ||
| 176 | + | ||
| 177 | + | ||
| 178 | +###################### | ||
| 179 | +# pp-scene-det | ||
| 180 | +###################### | ||
| 181 | +__pycache__/ | ||
| 182 | + | ||
| 183 | +###################### | ||
| 184 | +# live-stream-ai-task-workline | ||
| 185 | +###################### | ||
| 186 | +modules/basketball/weights | ||
| 187 | +modules/singer/weights | ||
| 188 | +weights/ | ||
| 189 | +weights1/ | ||
| 190 | +files/ | ||
| 191 | +*.log | ||
| 192 | +*.log.* | ||
| 193 | +cache_data/ | ||
| 194 | +data/ | ||
| 195 | +test/ | ||
| 196 | +tests/ | ||
| 197 | +# Media files | ||
| 198 | +*.jpg | ||
| 199 | +*.jpeg | ||
| 200 | +*.png | ||
| 201 | +*.gif | ||
| 202 | +*.mp4 | ||
| 203 | +*.avi | ||
| 204 | +*.mov | ||
| 205 | +*.mkv | ||
| 206 | + | ||
| 207 | +# OS | ||
| 208 | +.DS_Store | ||
| 209 | +Thumbs.db | ||
| 210 | +logs/ | ||
| 211 | cache/ | 211 | cache/ |
| 1 | -# ============================================================================ | ||
| 2 | -# embedding-server Dockerfile | ||
| 3 | -# ============================================================================ | ||
| 4 | -# 构建命令: | ||
| 5 | -# docker build -f Dockerfile.embedding -t embedding-server:latest . | ||
| 6 | -# | ||
| 7 | -# 运行命令: | ||
| 8 | -# docker run -d -p 8000:8000 --name embedding-server embedding-server:latest | ||
| 9 | -# ============================================================================ | ||
| 10 | - | ||
| 11 | -ARG PYTHON_VERSION=3.12 | ||
| 12 | -#ARG UV_VERSION=0.6.0 | ||
| 13 | - | ||
| 14 | -# ---------------------------------------------------------------------------- | ||
| 15 | -# 构建阶段 | ||
| 16 | -# ---------------------------------------------------------------------------- | ||
| 17 | -FROM python:${PYTHON_VERSION}-slim AS builder | ||
| 18 | -# 使用 Debian 的阿里云镜像 | ||
| 19 | -RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main contrib non-free" > /etc/apt/sources.list && \ | ||
| 20 | - echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main contrib non-free" >> /etc/apt/sources.list && \ | ||
| 21 | - echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main contrib non-free" >> /etc/apt/sources.list | ||
| 22 | - | ||
| 23 | -# 安装编译依赖和 uv | ||
| 24 | -RUN apt-get update \ | ||
| 25 | - && apt-get install -y --no-install-recommends gcc curl vim \ | ||
| 26 | - && rm -rf /var/lib/apt/lists/* | ||
| 27 | - | ||
| 28 | -RUN pip install --no-cache-dir uv | ||
| 29 | - | ||
| 30 | -WORKDIR /app | ||
| 31 | - | ||
| 32 | -# 复制项目文件(仅 embedding-server 所需) | ||
| 33 | -COPY pyproject.toml uv.lock ./ | ||
| 34 | -COPY src/embedding_sever/ ./src/embedding_sever/ | ||
| 35 | - | ||
| 36 | -# 安装依赖:核心依赖 + embedding 专用依赖 | ||
| 37 | -RUN uv sync --no-dev --no-editable --extra embedding | ||
| 38 | - | ||
| 39 | -# ---------------------------------------------------------------------------- | ||
| 40 | -# 运行阶段 | ||
| 41 | -# ---------------------------------------------------------------------------- | ||
| 42 | -FROM python:${PYTHON_VERSION}-slim | ||
| 43 | - | ||
| 44 | -WORKDIR /app | ||
| 45 | - | ||
| 46 | -# 从构建阶段复制虚拟环境和源码 | ||
| 47 | -COPY --from=builder /app/.venv /app/.venv | ||
| 48 | -COPY --from=builder /app/src/embedding_sever /app/src/embedding_sever | ||
| 49 | - | ||
| 50 | -# 环境变量 | ||
| 51 | -ENV PATH="/app/.venv/bin:$PATH" | ||
| 52 | -ENV PYTHONPATH="/app/src" | ||
| 53 | -ENV PROJECT_ROOT="/app/src" | ||
| 54 | -ENV PYTHONUNBUFFERED=1 | ||
| 55 | -ENV ENV=prod | ||
| 56 | -ENV APP_LOG_TYPE=file,console | ||
| 57 | - | ||
| 58 | -EXPOSE 8000 | ||
| 59 | - | ||
| 60 | -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ | ||
| 61 | - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1 | ||
| 62 | - | ||
| 63 | -CMD ["embedding-server"] | 1 | +# ============================================================================ |
| 2 | +# embedding-server Dockerfile | ||
| 3 | +# ============================================================================ | ||
| 4 | +# 构建命令: | ||
| 5 | +# docker build -f Dockerfile.embedding -t embedding-server:latest . | ||
| 6 | +# | ||
| 7 | +# 运行命令: | ||
| 8 | +# docker run -d -p 8000:8000 --name embedding-server embedding-server:latest | ||
| 9 | +# ============================================================================ | ||
| 10 | + | ||
| 11 | +ARG PYTHON_VERSION=3.12 | ||
| 12 | +#ARG UV_VERSION=0.6.0 | ||
| 13 | + | ||
| 14 | +# ---------------------------------------------------------------------------- | ||
| 15 | +# 构建阶段 | ||
| 16 | +# ---------------------------------------------------------------------------- | ||
| 17 | +FROM python:${PYTHON_VERSION}-slim AS builder | ||
| 18 | +# 使用 Debian 的阿里云镜像 | ||
| 19 | +RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main contrib non-free" > /etc/apt/sources.list && \ | ||
| 20 | + echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main contrib non-free" >> /etc/apt/sources.list && \ | ||
| 21 | + echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main contrib non-free" >> /etc/apt/sources.list | ||
| 22 | + | ||
| 23 | +# 安装编译依赖和 uv | ||
| 24 | +RUN apt-get update \ | ||
| 25 | + && apt-get install -y --no-install-recommends gcc curl vim \ | ||
| 26 | + && rm -rf /var/lib/apt/lists/* | ||
| 27 | + | ||
| 28 | +RUN pip install --no-cache-dir uv | ||
| 29 | + | ||
| 30 | +WORKDIR /app | ||
| 31 | + | ||
| 32 | +# 复制项目文件(仅 embedding-server 所需) | ||
| 33 | +COPY pyproject.toml uv.lock ./ | ||
| 34 | +COPY src/embedding_sever/ ./src/embedding_sever/ | ||
| 35 | + | ||
| 36 | +# 安装依赖:核心依赖 + embedding 专用依赖 | ||
| 37 | +RUN uv sync --no-dev --no-editable --extra embedding | ||
| 38 | + | ||
| 39 | +# ---------------------------------------------------------------------------- | ||
| 40 | +# 运行阶段 | ||
| 41 | +# ---------------------------------------------------------------------------- | ||
| 42 | +FROM python:${PYTHON_VERSION}-slim | ||
| 43 | + | ||
| 44 | +WORKDIR /app | ||
| 45 | + | ||
| 46 | +# 从构建阶段复制虚拟环境和源码 | ||
| 47 | +COPY --from=builder /app/.venv /app/.venv | ||
| 48 | +COPY --from=builder /app/src/embedding_sever /app/src/embedding_sever | ||
| 49 | + | ||
| 50 | +# 环境变量 | ||
| 51 | +ENV PATH="/app/.venv/bin:$PATH" | ||
| 52 | +ENV PYTHONPATH="/app/src" | ||
| 53 | +ENV PROJECT_ROOT="/app/src" | ||
| 54 | +ENV PYTHONUNBUFFERED=1 | ||
| 55 | +ENV ENV=prod | ||
| 56 | +ENV APP_LOG_TYPE=file,console | ||
| 57 | + | ||
| 58 | +EXPOSE 8000 | ||
| 59 | + | ||
| 60 | +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ | ||
| 61 | + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1 | ||
| 62 | + | ||
| 63 | +CMD ["embedding-server"] |
| 1 | -### 一些启动命令 | ||
| 2 | -1. 启动 embedding-server | ||
| 3 | - | ||
| 4 | -默认 | ||
| 5 | -```bash | ||
| 6 | -uv run embedding-server | ||
| 7 | -``` | ||
| 8 | -window 开发 | ||
| 9 | -```bash | ||
| 10 | -$env:ENV="dev";uv run embedding-server | ||
| 11 | -$env:ENV="dev";uv run football-repaly-match-server | ||
| 12 | -``` | ||
| 13 | - | ||
| 14 | -安装依赖 | ||
| 15 | -```shell | ||
| 16 | -uv sync --no-dev --no-editable --extra football-replay-match --extra embedding | 1 | +### 一些启动命令 |
| 2 | +1. 启动 embedding-server | ||
| 3 | + | ||
| 4 | +默认 | ||
| 5 | +```bash | ||
| 6 | +uv run embedding-server | ||
| 7 | +``` | ||
| 8 | +window 开发 | ||
| 9 | +```bash | ||
| 10 | +$env:ENV="dev";uv run embedding-server | ||
| 11 | +$env:ENV="dev";uv run football-repaly-match-server | ||
| 12 | +``` | ||
| 13 | + | ||
| 14 | +安装依赖 | ||
| 15 | +```shell | ||
| 16 | +uv sync --no-dev --no-editable --extra football-replay-match --extra embedding | ||
| 17 | ``` | 17 | ``` |
| 1 | -[build-system] | ||
| 2 | -requires = ["hatchling"] | ||
| 3 | -build-backend = "hatchling.build" | ||
| 4 | - | ||
| 5 | -[project] | ||
| 6 | -name = "ai_server" | ||
| 7 | -version = "0.1.0" | ||
| 8 | -description = "Add your description here" | ||
| 9 | -requires-python = ">=3.12" | ||
| 10 | -dependencies = [ | ||
| 11 | - "fastapi==0.136.1", | ||
| 12 | - "uvicorn[standard]==0.46.0", | ||
| 13 | - "pydantic==2.13.4", | ||
| 14 | - "numpy>=1.24.0", | ||
| 15 | - "aiohttp>=3.8.0", | ||
| 16 | - "aabd==0.4.8", | ||
| 17 | - "path==17.1.1", | ||
| 18 | - "omegaconf==2.3.0", | ||
| 19 | - "packaging==26.2", | ||
| 20 | -] | ||
| 21 | - | ||
| 22 | -[project.optional-dependencies] | ||
| 23 | -# embedding-server 专用依赖 | ||
| 24 | -embedding = [ | ||
| 25 | - "elasticsearch==9.3.0", | ||
| 26 | -] | ||
| 27 | -football-replay-match = [ | ||
| 28 | - "Pillow==12.2.0", | ||
| 29 | - "av==13.1.0", | ||
| 30 | - "confluent_kafka==2.6.1", | ||
| 31 | - "langchain-openai>=1.1.15", | ||
| 32 | - "opencv-contrib-python==4.13.0.92" | ||
| 33 | -] | ||
| 34 | -# task-server 专用依赖 | ||
| 35 | -task = [ | ||
| 36 | - | ||
| 37 | -] | ||
| 38 | - | ||
| 39 | -[tool.uv] | ||
| 40 | -package = true | ||
| 41 | - | ||
| 42 | -[tool.hatch.build.targets.wheel] | ||
| 43 | -packages = ["src/embedding_sever"] | ||
| 44 | - | ||
| 45 | -[project.scripts] | ||
| 46 | -embedding-server = "embedding_sever.main:main" | ||
| 47 | -#task-server = "task_server.main:main" | ||
| 48 | -football-repaly-match-server = "football_replay_match.main:main" | 1 | +[build-system] |
| 2 | +requires = ["hatchling"] | ||
| 3 | +build-backend = "hatchling.build" | ||
| 4 | + | ||
| 5 | +[project] | ||
| 6 | +name = "ai_server" | ||
| 7 | +version = "0.1.0" | ||
| 8 | +description = "Add your description here" | ||
| 9 | +requires-python = ">=3.12" | ||
| 10 | +dependencies = [ | ||
| 11 | + "fastapi==0.136.1", | ||
| 12 | + "uvicorn[standard]==0.46.0", | ||
| 13 | + "pydantic==2.13.4", | ||
| 14 | + "numpy>=1.24.0", | ||
| 15 | + "aiohttp>=3.8.0", | ||
| 16 | + "aabd==0.4.8", | ||
| 17 | + "path==17.1.1", | ||
| 18 | + "omegaconf==2.3.0", | ||
| 19 | + "packaging==26.2", | ||
| 20 | +] | ||
| 21 | + | ||
| 22 | +[project.optional-dependencies] | ||
| 23 | +# embedding-server 专用依赖 | ||
| 24 | +embedding = [ | ||
| 25 | + "elasticsearch==9.3.0", | ||
| 26 | +] | ||
| 27 | +football-replay-match = [ | ||
| 28 | + "Pillow==12.2.0", | ||
| 29 | + "av==13.1.0", | ||
| 30 | + "confluent_kafka==2.6.1", | ||
| 31 | + "langchain-openai>=1.1.15", | ||
| 32 | + "opencv-contrib-python==4.13.0.92" | ||
| 33 | +] | ||
| 34 | +# task-server 专用依赖 | ||
| 35 | +task = [ | ||
| 36 | + | ||
| 37 | +] | ||
| 38 | + | ||
| 39 | +[tool.uv] | ||
| 40 | +package = true | ||
| 41 | + | ||
| 42 | +[tool.hatch.build.targets.wheel] | ||
| 43 | +packages = ["src/embedding_sever"] | ||
| 44 | + | ||
| 45 | +[project.scripts] | ||
| 46 | +embedding-server = "embedding_sever.main:main" | ||
| 47 | +#task-server = "task_server.main:main" | ||
| 48 | +football-repaly-match-server = "football_replay_match.main:main" |
| 1 | -from typing import List, Optional, Literal, Union, Dict, Any | ||
| 2 | - | ||
| 3 | -from fastapi import APIRouter, Depends | ||
| 4 | -import numpy | ||
| 5 | -from pydantic import BaseModel, Field, TypeAdapter | ||
| 6 | - | ||
| 7 | -from embedding_sever.api.resp_bean import RespBean, success | ||
| 8 | - | ||
| 9 | -router = APIRouter() | ||
| 10 | - | ||
| 11 | - | ||
| 12 | -# ============== 请求/响应模型定义 ============== | ||
| 13 | - | ||
| 14 | -class FaceKwargs(BaseModel): | ||
| 15 | - """人脸类型扩展字段""" | ||
| 16 | - person_id: Optional[str] = Field(None, description="人物id") | ||
| 17 | - person_name: Optional[str] = Field(None, description="人物姓名") | ||
| 18 | - | ||
| 19 | - | ||
| 20 | -class SportShotKwargs(BaseModel): | ||
| 21 | - """体育镜头类型扩展字段""" | ||
| 22 | - match_name: Optional[str] = Field(None, description="比赛名称") | ||
| 23 | - person_name: Optional[str] = Field(None, description="人名") | ||
| 24 | - video_desc: Optional[str] = Field(None, description="视频描述") | ||
| 25 | - shot_cls: Optional[str] = Field(None, description="镜头分类") | ||
| 26 | - match_id: Optional[str] = Field(None, description="比赛id") | ||
| 27 | - program_id: Optional[str] = Field(None, description="节目id") | ||
| 28 | - | ||
| 29 | - | ||
| 30 | -# 定义类型映射,用于根据type自动转换kwargs | ||
| 31 | -KwargsType = Union[FaceKwargs, SportShotKwargs] | ||
| 32 | - | ||
| 33 | - | ||
| 34 | -class PutDataRequest(BaseModel): | ||
| 35 | - """写入数据请求体""" | ||
| 36 | - id: str = Field(..., description="唯一键id") | ||
| 37 | - embedding: List[float] = Field(..., description="向量") | ||
| 38 | - embedding_version: str = Field(..., description="向量版本 小写数字下划线组成") | ||
| 39 | - kwargs: Optional[Dict[str, Any]] = Field(None, description="扩展字段,根据type不同字段不同") | ||
| 40 | - | ||
| 41 | - def get_typed_kwargs(self, type: str) -> Optional[KwargsType]: | ||
| 42 | - """根据type自动转换kwargs为具体类型""" | ||
| 43 | - if self.kwargs is None: | ||
| 44 | - return None | ||
| 45 | - type_map = { | ||
| 46 | - 'face': FaceKwargs, | ||
| 47 | - 'sport_shot': SportShotKwargs | ||
| 48 | - } | ||
| 49 | - kwargs_class = type_map.get(type) | ||
| 50 | - if kwargs_class: | ||
| 51 | - return kwargs_class(**self.kwargs) | ||
| 52 | - return None | ||
| 53 | - | ||
| 54 | - | ||
| 55 | -class DelByIdRequest(BaseModel): | ||
| 56 | - """删除数据请求体""" | ||
| 57 | - ids: List[str] = Field(..., description="id 集合") | ||
| 58 | - embedding_version: str = Field(..., description="向量版本 小写数字下划线组成") | ||
| 59 | - | ||
| 60 | - | ||
| 61 | -class FilterItem(BaseModel): | ||
| 62 | - """过滤条件项""" | ||
| 63 | - name: str = Field(..., description="字段名") | ||
| 64 | - value: List = Field(..., description="字段值") | ||
| 65 | - opt: Optional[Literal["eq", "neq", "lt", "gt", "lte", "gte", "like", "in"]] = Field( | ||
| 66 | - None, description="操作类型: eq相等, neq不等, lt小于, gt大于, lte小于等于, gte大于等于, like模糊匹配, in内" | ||
| 67 | - ) | ||
| 68 | - | ||
| 69 | - | ||
| 70 | -class SearchRequest(BaseModel): | ||
| 71 | - """检索数据请求体""" | ||
| 72 | - embedding: List[float] = Field(..., description="向量") | ||
| 73 | - embedding_version: str = Field(..., description="向量版本 小写数字下划线组成") | ||
| 74 | - topk: int = Field(..., description="top k") | ||
| 75 | - filters: Optional[List[FilterItem]] = Field(None, description="过滤字段") | ||
| 76 | - | ||
| 77 | - | ||
| 78 | -class SearchResultItem(BaseModel): | ||
| 79 | - """检索结果单项""" | ||
| 80 | - id: str = Field(..., description="id") | ||
| 81 | - kwargs: dict = Field(..., description="扩展字段") | ||
| 82 | - score: float = Field(..., description="分值") | ||
| 83 | - | ||
| 84 | - | ||
| 85 | -class SearchResponseData(BaseModel): | ||
| 86 | - """检索响应数据""" | ||
| 87 | - type: str = Field(..., description="type") | ||
| 88 | - data_list: List[SearchResultItem] = Field(..., description="topk结果列表") | ||
| 89 | - | ||
| 90 | - | ||
| 91 | -# ============== 接口定义 ============== | ||
| 92 | -from embedding_sever.service.data_service import DataService | ||
| 93 | -from embedding_sever.service import get_data_service | ||
| 94 | - | ||
| 95 | - | ||
| 96 | -@router.put("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据") | ||
| 97 | -@router.post("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据") | ||
| 98 | -async def put_data( | ||
| 99 | - type: Literal["face", "sport_shot"], | ||
| 100 | - request: PutDataRequest, | ||
| 101 | - data_service: DataService = Depends(get_data_service) | ||
| 102 | -): | ||
| 103 | - """ | ||
| 104 | - 写入数据 | ||
| 105 | - """ | ||
| 106 | - request.embedding = numpy.array(request.embedding) | ||
| 107 | - | ||
| 108 | - embedding_version = request.embedding_version | ||
| 109 | - tb_name = f'{type}_{embedding_version}' | ||
| 110 | - upserted_id = await data_service.upsert(tb_name, request.id, request.embedding, | ||
| 111 | - request.get_typed_kwargs(type).model_dump()) | ||
| 112 | - | ||
| 113 | - return success(data={'id': upserted_id}) | ||
| 114 | - | ||
| 115 | - | ||
| 116 | -@router.delete("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据") | ||
| 117 | -@router.post("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据") | ||
| 118 | -async def del_by_id( | ||
| 119 | - type: Literal["face", "sport_shot"], | ||
| 120 | - request: DelByIdRequest, | ||
| 121 | - data_service: DataService = Depends(get_data_service) | ||
| 122 | -): | ||
| 123 | - """ | ||
| 124 | - 删除数据 | ||
| 125 | - | ||
| 126 | - - **type**: 数据类型 (face-人脸, sport_shot-体育镜头) | ||
| 127 | - - **ids**: id集合 | ||
| 128 | - """ | ||
| 129 | - embedding_version = request.embedding_version | ||
| 130 | - tb_name = f'{type}_{embedding_version}' | ||
| 131 | - await data_service.delete_by_pks(tb_name, request.ids) | ||
| 132 | - | ||
| 133 | - return success() | ||
| 134 | - | ||
| 135 | - | ||
| 136 | -@router.post("/{type}/search", response_model=RespBean[SearchResponseData], tags=["数据服务"], summary="检索数据") | ||
| 137 | -async def search_data( | ||
| 138 | - type: Literal["face", "sport_shot"], | ||
| 139 | - request: SearchRequest, | ||
| 140 | - data_service: DataService = Depends(get_data_service), | ||
| 141 | - adapter=TypeAdapter(List[SearchResultItem]) | ||
| 142 | -): | ||
| 143 | - """ | ||
| 144 | - 检索数据 | ||
| 145 | - | ||
| 146 | - - **type**: 数据类型 (face-人脸, sport_shot-体育镜头) | ||
| 147 | - - **embedding**: 查询向量 | ||
| 148 | - - **embedding_version**: 向量版本 | ||
| 149 | - - **topk**: 返回top k条结果 | ||
| 150 | - - **filters**: 过滤条件列表 | ||
| 151 | - """ | ||
| 152 | - # | ||
| 153 | - embedding_version = request.embedding_version | ||
| 154 | - tb_name = f'{type}_{embedding_version}' | ||
| 155 | - | ||
| 156 | - embedding = request.embedding | ||
| 157 | - filters = request.filters | ||
| 158 | - from_, size = 0, request.topk | ||
| 159 | - | ||
| 160 | - result_data = await data_service.search(tb_name, embedding, filters, from_, size) | ||
| 161 | - search_results: List[SearchResultItem] = adapter.validate_python(result_data) | ||
| 162 | - | ||
| 163 | - return success(data=SearchResponseData(type=type, data_list=search_results)) | 1 | +from typing import List, Optional, Literal, Union, Dict, Any |
| 2 | + | ||
| 3 | +from fastapi import APIRouter, Depends | ||
| 4 | +import numpy | ||
| 5 | +from pydantic import BaseModel, Field, TypeAdapter | ||
| 6 | + | ||
| 7 | +from embedding_sever.api.resp_bean import RespBean, success | ||
| 8 | + | ||
| 9 | +router = APIRouter() | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +# ============== 请求/响应模型定义 ============== | ||
| 13 | + | ||
| 14 | +class FaceKwargs(BaseModel): | ||
| 15 | + """人脸类型扩展字段""" | ||
| 16 | + person_id: Optional[str] = Field(None, description="人物id") | ||
| 17 | + person_name: Optional[str] = Field(None, description="人物姓名") | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +class SportShotKwargs(BaseModel): | ||
| 21 | + """体育镜头类型扩展字段""" | ||
| 22 | + match_name: Optional[str] = Field(None, description="比赛名称") | ||
| 23 | + person_name: Optional[str] = Field(None, description="人名") | ||
| 24 | + video_desc: Optional[str] = Field(None, description="视频描述") | ||
| 25 | + shot_cls: Optional[str] = Field(None, description="镜头分类") | ||
| 26 | + match_id: Optional[str] = Field(None, description="比赛id") | ||
| 27 | + program_id: Optional[str] = Field(None, description="节目id") | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +# 定义类型映射,用于根据type自动转换kwargs | ||
| 31 | +KwargsType = Union[FaceKwargs, SportShotKwargs] | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +class PutDataRequest(BaseModel): | ||
| 35 | + """写入数据请求体""" | ||
| 36 | + id: str = Field(..., description="唯一键id") | ||
| 37 | + embedding: List[float] = Field(..., description="向量") | ||
| 38 | + embedding_version: str = Field(..., description="向量版本 小写数字下划线组成") | ||
| 39 | + kwargs: Optional[Dict[str, Any]] = Field(None, description="扩展字段,根据type不同字段不同") | ||
| 40 | + | ||
| 41 | + def get_typed_kwargs(self, type: str) -> Optional[KwargsType]: | ||
| 42 | + """根据type自动转换kwargs为具体类型""" | ||
| 43 | + if self.kwargs is None: | ||
| 44 | + return None | ||
| 45 | + type_map = { | ||
| 46 | + 'face': FaceKwargs, | ||
| 47 | + 'sport_shot': SportShotKwargs | ||
| 48 | + } | ||
| 49 | + kwargs_class = type_map.get(type) | ||
| 50 | + if kwargs_class: | ||
| 51 | + return kwargs_class(**self.kwargs) | ||
| 52 | + return None | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +class DelByIdRequest(BaseModel): | ||
| 56 | + """删除数据请求体""" | ||
| 57 | + ids: List[str] = Field(..., description="id 集合") | ||
| 58 | + embedding_version: str = Field(..., description="向量版本 小写数字下划线组成") | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +class FilterItem(BaseModel): | ||
| 62 | + """过滤条件项""" | ||
| 63 | + name: str = Field(..., description="字段名") | ||
| 64 | + value: List = Field(..., description="字段值") | ||
| 65 | + opt: Optional[Literal["eq", "neq", "lt", "gt", "lte", "gte", "like", "in"]] = Field( | ||
| 66 | + None, description="操作类型: eq相等, neq不等, lt小于, gt大于, lte小于等于, gte大于等于, like模糊匹配, in内" | ||
| 67 | + ) | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +class SearchRequest(BaseModel): | ||
| 71 | + """检索数据请求体""" | ||
| 72 | + embedding: List[float] = Field(..., description="向量") | ||
| 73 | + embedding_version: str = Field(..., description="向量版本 小写数字下划线组成") | ||
| 74 | + topk: int = Field(..., description="top k") | ||
| 75 | + filters: Optional[List[FilterItem]] = Field(None, description="过滤字段") | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +class SearchResultItem(BaseModel): | ||
| 79 | + """检索结果单项""" | ||
| 80 | + id: str = Field(..., description="id") | ||
| 81 | + kwargs: dict = Field(..., description="扩展字段") | ||
| 82 | + score: float = Field(..., description="分值") | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +class SearchResponseData(BaseModel): | ||
| 86 | + """检索响应数据""" | ||
| 87 | + type: str = Field(..., description="type") | ||
| 88 | + data_list: List[SearchResultItem] = Field(..., description="topk结果列表") | ||
| 89 | + | ||
| 90 | + | ||
| 91 | +# ============== 接口定义 ============== | ||
| 92 | +from embedding_sever.service.data_service import DataService | ||
| 93 | +from embedding_sever.service import get_data_service | ||
| 94 | + | ||
| 95 | + | ||
| 96 | +@router.put("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据") | ||
| 97 | +@router.post("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据") | ||
| 98 | +async def put_data( | ||
| 99 | + type: Literal["face", "sport_shot"], | ||
| 100 | + request: PutDataRequest, | ||
| 101 | + data_service: DataService = Depends(get_data_service) | ||
| 102 | +): | ||
| 103 | + """ | ||
| 104 | + 写入数据 | ||
| 105 | + """ | ||
| 106 | + request.embedding = numpy.array(request.embedding) | ||
| 107 | + | ||
| 108 | + embedding_version = request.embedding_version | ||
| 109 | + tb_name = f'{type}_{embedding_version}' | ||
| 110 | + upserted_id = await data_service.upsert(tb_name, request.id, request.embedding, | ||
| 111 | + request.get_typed_kwargs(type).model_dump()) | ||
| 112 | + | ||
| 113 | + return success(data={'id': upserted_id}) | ||
| 114 | + | ||
| 115 | + | ||
| 116 | +@router.delete("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据") | ||
| 117 | +@router.post("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据") | ||
| 118 | +async def del_by_id( | ||
| 119 | + type: Literal["face", "sport_shot"], | ||
| 120 | + request: DelByIdRequest, | ||
| 121 | + data_service: DataService = Depends(get_data_service) | ||
| 122 | +): | ||
| 123 | + """ | ||
| 124 | + 删除数据 | ||
| 125 | + | ||
| 126 | + - **type**: 数据类型 (face-人脸, sport_shot-体育镜头) | ||
| 127 | + - **ids**: id集合 | ||
| 128 | + """ | ||
| 129 | + embedding_version = request.embedding_version | ||
| 130 | + tb_name = f'{type}_{embedding_version}' | ||
| 131 | + await data_service.delete_by_pks(tb_name, request.ids) | ||
| 132 | + | ||
| 133 | + return success() | ||
| 134 | + | ||
| 135 | + | ||
| 136 | +@router.post("/{type}/search", response_model=RespBean[SearchResponseData], tags=["数据服务"], summary="检索数据") | ||
| 137 | +async def search_data( | ||
| 138 | + type: Literal["face", "sport_shot"], | ||
| 139 | + request: SearchRequest, | ||
| 140 | + data_service: DataService = Depends(get_data_service), | ||
| 141 | + adapter=TypeAdapter(List[SearchResultItem]) | ||
| 142 | +): | ||
| 143 | + """ | ||
| 144 | + 检索数据 | ||
| 145 | + | ||
| 146 | + - **type**: 数据类型 (face-人脸, sport_shot-体育镜头) | ||
| 147 | + - **embedding**: 查询向量 | ||
| 148 | + - **embedding_version**: 向量版本 | ||
| 149 | + - **topk**: 返回top k条结果 | ||
| 150 | + - **filters**: 过滤条件列表 | ||
| 151 | + """ | ||
| 152 | + # | ||
| 153 | + embedding_version = request.embedding_version | ||
| 154 | + tb_name = f'{type}_{embedding_version}' | ||
| 155 | + | ||
| 156 | + embedding = request.embedding | ||
| 157 | + filters = request.filters | ||
| 158 | + from_, size = 0, request.topk | ||
| 159 | + | ||
| 160 | + result_data = await data_service.search(tb_name, embedding, filters, from_, size) | ||
| 161 | + search_results: List[SearchResultItem] = adapter.validate_python(result_data) | ||
| 162 | + | ||
| 163 | + return success(data=SearchResponseData(type=type, data_list=search_results)) |
| 1 | -from fastapi.middleware.cors import CORSMiddleware | ||
| 2 | -from starlette.responses import JSONResponse | ||
| 3 | -from embedding_sever.api.http_log import LoggingMiddleware | ||
| 4 | -from fastapi import FastAPI | ||
| 5 | -from embedding_sever.api.router import api_router | ||
| 6 | -from embedding_sever.config import settings | ||
| 7 | - | ||
| 8 | - | ||
| 9 | -def config_fastapi(fastapi_app: FastAPI): | ||
| 10 | - # 配置请求/响应日志中间件(需在CORS之前添加) | ||
| 11 | - fastapi_app.add_middleware(LoggingMiddleware) | ||
| 12 | - | ||
| 13 | - # 配置CORS | ||
| 14 | - fastapi_app.add_middleware( | ||
| 15 | - CORSMiddleware, | ||
| 16 | - allow_origins=["*"], | ||
| 17 | - allow_credentials=True, | ||
| 18 | - allow_methods=["*"], | ||
| 19 | - allow_headers=["*"], | ||
| 20 | - ) | ||
| 21 | - | ||
| 22 | - # 注册路由 | ||
| 23 | - fastapi_app.include_router(api_router) | ||
| 24 | - | ||
| 25 | - @fastapi_app.exception_handler(Exception) | ||
| 26 | - async def generic_exception_handler(request, exc: Exception): | ||
| 27 | - # 返回自定义的错误响应 | ||
| 28 | - error_detail = str(exc) | ||
| 29 | - return JSONResponse( | ||
| 30 | - status_code=200, | ||
| 31 | - content={"code": "error", "message": error_detail}, | ||
| 32 | - ) | ||
| 33 | - | ||
| 34 | - @fastapi_app.get("/", tags=["根路径"]) | ||
| 35 | - async def root(): | ||
| 36 | - """根路径.""" | ||
| 37 | - return { | ||
| 38 | - "name": settings.app_name, | ||
| 39 | - "version": settings.app_version, | ||
| 40 | - "docs": "/docs", | ||
| 41 | - } | ||
| 42 | - | ||
| 43 | - return fastapi_app | 1 | +from fastapi.middleware.cors import CORSMiddleware |
| 2 | +from starlette.responses import JSONResponse | ||
| 3 | +from embedding_sever.api.http_log import LoggingMiddleware | ||
| 4 | +from fastapi import FastAPI | ||
| 5 | +from embedding_sever.api.router import api_router | ||
| 6 | +from embedding_sever.config import settings | ||
| 7 | + | ||
| 8 | + | ||
| 9 | +def config_fastapi(fastapi_app: FastAPI): | ||
| 10 | + # 配置请求/响应日志中间件(需在CORS之前添加) | ||
| 11 | + fastapi_app.add_middleware(LoggingMiddleware) | ||
| 12 | + | ||
| 13 | + # 配置CORS | ||
| 14 | + fastapi_app.add_middleware( | ||
| 15 | + CORSMiddleware, | ||
| 16 | + allow_origins=["*"], | ||
| 17 | + allow_credentials=True, | ||
| 18 | + allow_methods=["*"], | ||
| 19 | + allow_headers=["*"], | ||
| 20 | + ) | ||
| 21 | + | ||
| 22 | + # 注册路由 | ||
| 23 | + fastapi_app.include_router(api_router) | ||
| 24 | + | ||
| 25 | + @fastapi_app.exception_handler(Exception) | ||
| 26 | + async def generic_exception_handler(request, exc: Exception): | ||
| 27 | + # 返回自定义的错误响应 | ||
| 28 | + error_detail = str(exc) | ||
| 29 | + return JSONResponse( | ||
| 30 | + status_code=200, | ||
| 31 | + content={"code": "error", "message": error_detail}, | ||
| 32 | + ) | ||
| 33 | + | ||
| 34 | + @fastapi_app.get("/", tags=["根路径"]) | ||
| 35 | + async def root(): | ||
| 36 | + """根路径.""" | ||
| 37 | + return { | ||
| 38 | + "name": settings.app_name, | ||
| 39 | + "version": settings.app_version, | ||
| 40 | + "docs": "/docs", | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + return fastapi_app |
| 1 | -import json | ||
| 2 | -import time | ||
| 3 | -import logging | ||
| 4 | - | ||
| 5 | -logger = logging.getLogger(__name__) | ||
| 6 | -from fastapi import Request | ||
| 7 | -from starlette.middleware.base import BaseHTTPMiddleware | ||
| 8 | -from starlette.responses import StreamingResponse | ||
| 9 | -from embedding_sever.config import settings | ||
| 10 | - | ||
| 11 | - | ||
| 12 | -class LoggingMiddleware(BaseHTTPMiddleware): | ||
| 13 | - | ||
| 14 | - async def dispatch(self, request: Request, call_next): | ||
| 15 | - # 获取请求信息 | ||
| 16 | - method = request.method | ||
| 17 | - url = str(request.url) | ||
| 18 | - client_host = request.client.host if request.client else "unknown" | ||
| 19 | - | ||
| 20 | - # 判断是否需要记录日志 | ||
| 21 | - should_log = request.url.path.startswith(settings.url_prefix) | ||
| 22 | - | ||
| 23 | - if should_log: | ||
| 24 | - # 记录请求开始时间 | ||
| 25 | - start_time = time.time() | ||
| 26 | - | ||
| 27 | - # 读取请求体 | ||
| 28 | - request_body = None | ||
| 29 | - if method in ("POST", "PUT", "PATCH"): | ||
| 30 | - try: | ||
| 31 | - body = await request.body() | ||
| 32 | - if body: | ||
| 33 | - request_body = body.decode("utf-8", errors="replace") | ||
| 34 | - # 尝试格式化为JSON | ||
| 35 | - try: | ||
| 36 | - request_body = json.loads(request_body) | ||
| 37 | - except json.JSONDecodeError: | ||
| 38 | - pass # 保持原始字符串 | ||
| 39 | - except Exception: | ||
| 40 | - pass | ||
| 41 | - | ||
| 42 | - # 处理请求 | ||
| 43 | - response = await call_next(request) | ||
| 44 | - | ||
| 45 | - # 计算处理时间 | ||
| 46 | - process_time = time.time() - start_time | ||
| 47 | - | ||
| 48 | - # 读取响应体(仅处理非流式响应) | ||
| 49 | - response_body = None | ||
| 50 | - if not isinstance(response, StreamingResponse): | ||
| 51 | - try: | ||
| 52 | - response_body_bytes = b"" | ||
| 53 | - async for chunk in response.body_iterator: | ||
| 54 | - response_body_bytes += chunk | ||
| 55 | - | ||
| 56 | - # 重新构建响应 | ||
| 57 | - response_body = response_body_bytes.decode("utf-8", errors="replace") | ||
| 58 | - # 尝试格式化为JSON | ||
| 59 | - try: | ||
| 60 | - response_body = json.loads(response_body) | ||
| 61 | - except json.JSONDecodeError: | ||
| 62 | - pass # 保持原始字符串 | ||
| 63 | - | ||
| 64 | - # 重建响应对象 | ||
| 65 | - response = StreamingResponse( | ||
| 66 | - iter([response_body_bytes]), | ||
| 67 | - status_code=response.status_code, | ||
| 68 | - headers=dict(response.headers), | ||
| 69 | - media_type=response.media_type, | ||
| 70 | - ) | ||
| 71 | - except Exception: | ||
| 72 | - pass | ||
| 73 | - | ||
| 74 | - # 构建单条日志记录 | ||
| 75 | - status_code = response.status_code | ||
| 76 | - log_data = { | ||
| 77 | - "method": method, | ||
| 78 | - "url": url, | ||
| 79 | - "client": client_host, | ||
| 80 | - "status": status_code, | ||
| 81 | - "time": f"{process_time:.3f}s" | ||
| 82 | - } | ||
| 83 | - | ||
| 84 | - if request_body: | ||
| 85 | - log_data["request_body"] = request_body | ||
| 86 | - if response_body: | ||
| 87 | - log_data["response_body"] = response_body | ||
| 88 | - | ||
| 89 | - logger.info(json.dumps(log_data, ensure_ascii=False)) | ||
| 90 | - | ||
| 91 | - return response | ||
| 92 | - else: | ||
| 93 | - # 不需要记录日志的请求,直接处理 | ||
| 94 | - return await call_next(request) | 1 | +import json |
| 2 | +import time | ||
| 3 | +import logging | ||
| 4 | + | ||
| 5 | +logger = logging.getLogger(__name__) | ||
| 6 | +from fastapi import Request | ||
| 7 | +from starlette.middleware.base import BaseHTTPMiddleware | ||
| 8 | +from starlette.responses import StreamingResponse | ||
| 9 | +from embedding_sever.config import settings | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +class LoggingMiddleware(BaseHTTPMiddleware): | ||
| 13 | + | ||
| 14 | + async def dispatch(self, request: Request, call_next): | ||
| 15 | + # 获取请求信息 | ||
| 16 | + method = request.method | ||
| 17 | + url = str(request.url) | ||
| 18 | + client_host = request.client.host if request.client else "unknown" | ||
| 19 | + | ||
| 20 | + # 判断是否需要记录日志 | ||
| 21 | + should_log = request.url.path.startswith(settings.url_prefix) | ||
| 22 | + | ||
| 23 | + if should_log: | ||
| 24 | + # 记录请求开始时间 | ||
| 25 | + start_time = time.time() | ||
| 26 | + | ||
| 27 | + # 读取请求体 | ||
| 28 | + request_body = None | ||
| 29 | + if method in ("POST", "PUT", "PATCH"): | ||
| 30 | + try: | ||
| 31 | + body = await request.body() | ||
| 32 | + if body: | ||
| 33 | + request_body = body.decode("utf-8", errors="replace") | ||
| 34 | + # 尝试格式化为JSON | ||
| 35 | + try: | ||
| 36 | + request_body = json.loads(request_body) | ||
| 37 | + except json.JSONDecodeError: | ||
| 38 | + pass # 保持原始字符串 | ||
| 39 | + except Exception: | ||
| 40 | + pass | ||
| 41 | + | ||
| 42 | + # 处理请求 | ||
| 43 | + response = await call_next(request) | ||
| 44 | + | ||
| 45 | + # 计算处理时间 | ||
| 46 | + process_time = time.time() - start_time | ||
| 47 | + | ||
| 48 | + # 读取响应体(仅处理非流式响应) | ||
| 49 | + response_body = None | ||
| 50 | + if not isinstance(response, StreamingResponse): | ||
| 51 | + try: | ||
| 52 | + response_body_bytes = b"" | ||
| 53 | + async for chunk in response.body_iterator: | ||
| 54 | + response_body_bytes += chunk | ||
| 55 | + | ||
| 56 | + # 重新构建响应 | ||
| 57 | + response_body = response_body_bytes.decode("utf-8", errors="replace") | ||
| 58 | + # 尝试格式化为JSON | ||
| 59 | + try: | ||
| 60 | + response_body = json.loads(response_body) | ||
| 61 | + except json.JSONDecodeError: | ||
| 62 | + pass # 保持原始字符串 | ||
| 63 | + | ||
| 64 | + # 重建响应对象 | ||
| 65 | + response = StreamingResponse( | ||
| 66 | + iter([response_body_bytes]), | ||
| 67 | + status_code=response.status_code, | ||
| 68 | + headers=dict(response.headers), | ||
| 69 | + media_type=response.media_type, | ||
| 70 | + ) | ||
| 71 | + except Exception: | ||
| 72 | + pass | ||
| 73 | + | ||
| 74 | + # 构建单条日志记录 | ||
| 75 | + status_code = response.status_code | ||
| 76 | + log_data = { | ||
| 77 | + "method": method, | ||
| 78 | + "url": url, | ||
| 79 | + "client": client_host, | ||
| 80 | + "status": status_code, | ||
| 81 | + "time": f"{process_time:.3f}s" | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + if request_body: | ||
| 85 | + log_data["request_body"] = request_body | ||
| 86 | + if response_body: | ||
| 87 | + log_data["response_body"] = response_body | ||
| 88 | + | ||
| 89 | + logger.info(json.dumps(log_data, ensure_ascii=False)) | ||
| 90 | + | ||
| 91 | + return response | ||
| 92 | + else: | ||
| 93 | + # 不需要记录日志的请求,直接处理 | ||
| 94 | + return await call_next(request) |
| 1 | -from pydantic import BaseModel, Field | ||
| 2 | -from typing import Generic, TypeVar, Optional | ||
| 3 | - | ||
| 4 | -T = TypeVar('T') | ||
| 5 | - | ||
| 6 | - | ||
| 7 | -class RespBean(BaseModel, Generic[T]): | ||
| 8 | - code: str = Field(default="success", description="code") | ||
| 9 | - message: str = Field(default="success", description='message') | ||
| 10 | - data: Optional[T] = Field(description="数据", default=None) | ||
| 11 | - | ||
| 12 | - | ||
| 13 | -def error(message): | ||
| 14 | - return RespBean(code="error", message=message, data=None) | ||
| 15 | - | ||
| 16 | - | ||
| 17 | -def success(data=None): | ||
| 18 | - return RespBean(data=data) | 1 | +from pydantic import BaseModel, Field |
| 2 | +from typing import Generic, TypeVar, Optional | ||
| 3 | + | ||
| 4 | +T = TypeVar('T') | ||
| 5 | + | ||
| 6 | + | ||
| 7 | +class RespBean(BaseModel, Generic[T]): | ||
| 8 | + code: str = Field(default="success", description="code") | ||
| 9 | + message: str = Field(default="success", description='message') | ||
| 10 | + data: Optional[T] = Field(description="数据", default=None) | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def error(message): | ||
| 14 | + return RespBean(code="error", message=message, data=None) | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def success(data=None): | ||
| 18 | + return RespBean(data=data) |
| 1 | -from fastapi import APIRouter | ||
| 2 | -from embedding_sever.config import settings | ||
| 3 | -from embedding_sever.api.data_router import router as data_router | ||
| 4 | - | ||
| 5 | -api_router = APIRouter(prefix=settings.url_prefix) | ||
| 6 | - | ||
| 7 | - | ||
| 8 | -def register_router(router: APIRouter, prefix: str): | ||
| 9 | - api_router.include_router(router, prefix=prefix) | ||
| 10 | - | ||
| 11 | - | ||
| 12 | -# 注册数据路由 | ||
| 13 | -register_router(data_router, prefix="/data") | 1 | +from fastapi import APIRouter |
| 2 | +from embedding_sever.config import settings | ||
| 3 | +from embedding_sever.api.data_router import router as data_router | ||
| 4 | + | ||
| 5 | +api_router = APIRouter(prefix=settings.url_prefix) | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +def register_router(router: APIRouter, prefix: str): | ||
| 9 | + api_router.include_router(router, prefix=prefix) | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +# 注册数据路由 | ||
| 13 | +register_router(data_router, prefix="/data") |
| 1 | -app_name: "Embedding Service" | ||
| 2 | -app_version: "1.0.0" | ||
| 3 | -port: 8000 | ||
| 4 | -debug: false | ||
| 5 | -url_prefix: "/aigc-embedding/api" | ||
| 6 | - | ||
| 7 | -db_es_enable: true | ||
| 8 | -db_es_url: "http://192.168.0.14:9200" | ||
| 9 | - | ||
| 10 | -#db_postgres_enable: false | 1 | +app_name: "Embedding Service" |
| 2 | +app_version: "1.0.0" | ||
| 3 | +port: 8000 | ||
| 4 | +debug: false | ||
| 5 | +url_prefix: "/aigc-embedding/api" | ||
| 6 | + | ||
| 7 | +db_es_enable: true | ||
| 8 | +db_es_url: "http://192.168.0.14:9200" | ||
| 9 | + | ||
| 10 | +#db_postgres_enable: false | ||
| 11 | #db_postgres_url: "postgresql://postgres:postgres@localhost:5432/postgres" | 11 | #db_postgres_url: "postgresql://postgres:postgres@localhost:5432/postgres" |
| 1 | -app_name: "Embedding Service" | ||
| 2 | -app_version: "1.0.0" | ||
| 3 | -port: 8000 | ||
| 4 | -debug: false | ||
| 5 | -url_prefix: "/aigc-embedding/api" | ||
| 6 | - | ||
| 7 | -db_es_enable: true | ||
| 8 | -db_es_url: "http://localhost:9200" | ||
| 9 | - | ||
| 10 | -#db_postgres_enable: false | 1 | +app_name: "Embedding Service" |
| 2 | +app_version: "1.0.0" | ||
| 3 | +port: 8000 | ||
| 4 | +debug: false | ||
| 5 | +url_prefix: "/aigc-embedding/api" | ||
| 6 | + | ||
| 7 | +db_es_enable: true | ||
| 8 | +db_es_url: "http://localhost:9200" | ||
| 9 | + | ||
| 10 | +#db_postgres_enable: false | ||
| 11 | #db_postgres_url: "postgresql://postgres:postgres@localhost:5432/postgres" | 11 | #db_postgres_url: "postgresql://postgres:postgres@localhost:5432/postgres" |
| 1 | -app_name: "Embedding Service" | ||
| 2 | -app_version: "1.0.0" | ||
| 3 | -port: 8000 | ||
| 4 | -debug: false | ||
| 5 | -url_prefix: "/aigc-embedding/api" | ||
| 6 | - | ||
| 7 | -db_es_enable: true | ||
| 8 | -db_es_url: "http://localhost:9200" | ||
| 9 | - | ||
| 10 | -#db_postgres_enable: false | 1 | +app_name: "Embedding Service" |
| 2 | +app_version: "1.0.0" | ||
| 3 | +port: 8000 | ||
| 4 | +debug: false | ||
| 5 | +url_prefix: "/aigc-embedding/api" | ||
| 6 | + | ||
| 7 | +db_es_enable: true | ||
| 8 | +db_es_url: "http://localhost:9200" | ||
| 9 | + | ||
| 10 | +#db_postgres_enable: false | ||
| 11 | #db_postgres_url: "postgresql://postgres:postgres@localhost:5432/postgres" | 11 | #db_postgres_url: "postgresql://postgres:postgres@localhost:5432/postgres" |
| 1 | -from aabd.base.cfg_loader import load_yaml_by_file_with_env | ||
| 2 | -from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | ||
| 3 | -from pathlib import Path | ||
| 4 | -from logging import getLogger | ||
| 5 | -logger = getLogger(__name__) | ||
| 6 | -cfg_dir = Path(__file__).parent.absolute() / 'cfg' | ||
| 7 | -settings = EnhanceDict(load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix())) | ||
| 8 | -settings.update(read_prefixed_env_vars('EMB_')) | ||
| 9 | - | 1 | +from aabd.base.cfg_loader import load_yaml_by_file_with_env |
| 2 | +from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | ||
| 3 | +from pathlib import Path | ||
| 4 | +from logging import getLogger | ||
| 5 | +logger = getLogger(__name__) | ||
| 6 | +cfg_dir = Path(__file__).parent.absolute() / 'cfg' | ||
| 7 | +settings = EnhanceDict(load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix())) | ||
| 8 | +settings.update(read_prefixed_env_vars('EMB_')) | ||
| 9 | + | ||
| 10 | logger.info(f'Settings: {settings}') | 10 | logger.info(f'Settings: {settings}') |
| 1 | -from functools import lru_cache | ||
| 2 | - | ||
| 3 | -from elasticsearch import AsyncElasticsearch | ||
| 4 | - | ||
| 5 | -from embedding_sever.config import settings | ||
| 6 | -from .data_dao import DataDao | ||
| 7 | - | ||
| 8 | -es_client = None | ||
| 9 | -pg_client = None | ||
| 10 | - | ||
| 11 | - | ||
| 12 | -async def connect(): | ||
| 13 | - if settings.db_es_enable: | ||
| 14 | - global es_client | ||
| 15 | - es_client = AsyncElasticsearch([settings.db_es_url]) | ||
| 16 | - await es_client.ping() | ||
| 17 | - if settings.db_postgres_enable: | ||
| 18 | - from .postgres_client import get_pg_client | ||
| 19 | - global pg_client | ||
| 20 | - pg_client = get_pg_client() | ||
| 21 | - await pg_client.connect() | ||
| 22 | - | ||
| 23 | - | ||
| 24 | -async def disconnect(): | ||
| 25 | - global es_client, pg_client | ||
| 26 | - if es_client is not None: | ||
| 27 | - await es_client.disconnect() | ||
| 28 | - es_client = None | ||
| 29 | - if pg_client is not None: | ||
| 30 | - await pg_client.disconnect() | ||
| 31 | - | ||
| 32 | - | ||
| 33 | -@lru_cache() | ||
| 34 | -def get_data_dao(): | ||
| 35 | - return DataDao(es_client) | 1 | +from functools import lru_cache |
| 2 | + | ||
| 3 | +from elasticsearch import AsyncElasticsearch | ||
| 4 | + | ||
| 5 | +from embedding_sever.config import settings | ||
| 6 | +from .data_dao import DataDao | ||
| 7 | + | ||
| 8 | +es_client = None | ||
| 9 | +pg_client = None | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +async def connect(): | ||
| 13 | + if settings.db_es_enable: | ||
| 14 | + global es_client | ||
| 15 | + es_client = AsyncElasticsearch([settings.db_es_url]) | ||
| 16 | + await es_client.ping() | ||
| 17 | + if settings.db_postgres_enable: | ||
| 18 | + from .postgres_client import get_pg_client | ||
| 19 | + global pg_client | ||
| 20 | + pg_client = get_pg_client() | ||
| 21 | + await pg_client.connect() | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +async def disconnect(): | ||
| 25 | + global es_client, pg_client | ||
| 26 | + if es_client is not None: | ||
| 27 | + await es_client.disconnect() | ||
| 28 | + es_client = None | ||
| 29 | + if pg_client is not None: | ||
| 30 | + await pg_client.disconnect() | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +@lru_cache() | ||
| 34 | +def get_data_dao(): | ||
| 35 | + return DataDao(es_client) |
| 1 | -"""数据库抽象基类,定义统一的CRUD接口.""" | ||
| 2 | - | ||
| 3 | -from abc import ABC, abstractmethod | ||
| 4 | -from typing import Any, Dict, List, Optional | ||
| 5 | - | ||
| 6 | - | ||
| 7 | -class VectorDBClient(ABC): | ||
| 8 | - """向量数据库客户端抽象基类. | ||
| 9 | - | ||
| 10 | - 定义了统一的向量数据CRUD接口,支持Elasticsearch和PostgreSQL等后端。 | ||
| 11 | - """ | ||
| 12 | - | ||
| 13 | - @abstractmethod | ||
| 14 | - async def connect(self) -> None: | ||
| 15 | - """建立数据库连接.""" | ||
| 16 | - pass | ||
| 17 | - | ||
| 18 | - @abstractmethod | ||
| 19 | - async def disconnect(self) -> None: | ||
| 20 | - """断开数据库连接.""" | ||
| 21 | - pass | ||
| 22 | - | ||
| 23 | - @abstractmethod | ||
| 24 | - async def health_check(self) -> bool: | ||
| 25 | - """检查数据库连接健康状态. | ||
| 26 | - | ||
| 27 | - Returns: | ||
| 28 | - bool: 连接正常返回True,否则返回False | ||
| 29 | - """ | ||
| 30 | - pass | ||
| 31 | - | ||
| 32 | - | ||
| 33 | -class TableInit(ABC): | ||
| 34 | - | ||
| 35 | - @abstractmethod | ||
| 36 | - async def create_table(self, name, embedding_version, embedding_dim) -> None: | ||
| 37 | - pass | 1 | +"""数据库抽象基类,定义统一的CRUD接口.""" |
| 2 | + | ||
| 3 | +from abc import ABC, abstractmethod | ||
| 4 | +from typing import Any, Dict, List, Optional | ||
| 5 | + | ||
| 6 | + | ||
| 7 | +class VectorDBClient(ABC): | ||
| 8 | + """向量数据库客户端抽象基类. | ||
| 9 | + | ||
| 10 | + 定义了统一的向量数据CRUD接口,支持Elasticsearch和PostgreSQL等后端。 | ||
| 11 | + """ | ||
| 12 | + | ||
| 13 | + @abstractmethod | ||
| 14 | + async def connect(self) -> None: | ||
| 15 | + """建立数据库连接.""" | ||
| 16 | + pass | ||
| 17 | + | ||
| 18 | + @abstractmethod | ||
| 19 | + async def disconnect(self) -> None: | ||
| 20 | + """断开数据库连接.""" | ||
| 21 | + pass | ||
| 22 | + | ||
| 23 | + @abstractmethod | ||
| 24 | + async def health_check(self) -> bool: | ||
| 25 | + """检查数据库连接健康状态. | ||
| 26 | + | ||
| 27 | + Returns: | ||
| 28 | + bool: 连接正常返回True,否则返回False | ||
| 29 | + """ | ||
| 30 | + pass | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +class TableInit(ABC): | ||
| 34 | + | ||
| 35 | + @abstractmethod | ||
| 36 | + async def create_table(self, name, embedding_version, embedding_dim) -> None: | ||
| 37 | + pass |
| 1 | -import json | ||
| 2 | -import logging | ||
| 3 | -import os | ||
| 4 | -import threading | ||
| 5 | -import typing | ||
| 6 | - | ||
| 7 | -from elasticsearch import AsyncElasticsearch | ||
| 8 | -import numpy | ||
| 9 | - | ||
| 10 | -logger = logging.getLogger(__name__) | ||
| 11 | - | ||
| 12 | - | ||
| 13 | -class DataDao: | ||
| 14 | - | ||
| 15 | - def __init__(self, es_client: AsyncElasticsearch): | ||
| 16 | - self.es_client = es_client | ||
| 17 | - | ||
| 18 | - self._lock = threading.RLock() | ||
| 19 | - | ||
| 20 | - async def search(self, tb_name: str, | ||
| 21 | - embedding=typing.Optional[typing.Union[numpy.ndarray, list]], | ||
| 22 | - filters=None, | ||
| 23 | - from_=typing.Optional[int], | ||
| 24 | - size=typing.Optional[int], | ||
| 25 | - ): | ||
| 26 | - search_body = dict() | ||
| 27 | - | ||
| 28 | - from_ = from_ if isinstance(from_, int) and from_ >= 0 else 0 | ||
| 29 | - size = size if isinstance(size, int) and size > 0 else 10 | ||
| 30 | - search_body.update({ | ||
| 31 | - 'from': from_, | ||
| 32 | - 'size': size, | ||
| 33 | - }) | ||
| 34 | - | ||
| 35 | - return_embedding = os.getenv('RETURN_EMBEDDING', 'false').lower().strip() | ||
| 36 | - if return_embedding != 'true': | ||
| 37 | - search_body.update({ | ||
| 38 | - "_source": { | ||
| 39 | - "excludes": ["embedding"] | ||
| 40 | - } | ||
| 41 | - }) | ||
| 42 | - | ||
| 43 | - condition_query = { | ||
| 44 | - "match_all": {} | ||
| 45 | - } | ||
| 46 | - if isinstance(filters, typing.Collection) and len(filters) > 0: | ||
| 47 | - tb_mapping = await self._get_mapping(tb_name) | ||
| 48 | - exist_properties = tb_mapping.get('mappings', {}).get('properties', {}) | ||
| 49 | - | ||
| 50 | - clauses = [] | ||
| 51 | - for filter in filters: | ||
| 52 | - k = filter.name | ||
| 53 | - v = filter.value | ||
| 54 | - opt = filter.opt | ||
| 55 | - if k not in exist_properties: | ||
| 56 | - continue | ||
| 57 | - clause = get_filter_clause(k, v, opt, exist_properties[k]) | ||
| 58 | - if clause: | ||
| 59 | - clauses.append(clause) | ||
| 60 | - condition_query = { | ||
| 61 | - "bool": { | ||
| 62 | - "filter": clauses | ||
| 63 | - } | ||
| 64 | - } | ||
| 65 | - if embedding is not None: | ||
| 66 | - use_knn = os.getenv('USE_KNN', 'false').lower().strip() | ||
| 67 | - if use_knn != 'true': | ||
| 68 | - query_body = { | ||
| 69 | - "script_score": { | ||
| 70 | - "query": condition_query, | ||
| 71 | - "script": { | ||
| 72 | - "source": """ | ||
| 73 | - // 1. 计算原始分数 (范围 [0, 2]) | ||
| 74 | - double rawScore = cosineSimilarity(params.query_vector, 'embedding') + 1.0; | ||
| 75 | - | ||
| 76 | - // 2. 归一化到 [0, 1] | ||
| 77 | - double normalizedScore = rawScore / 2.0; | ||
| 78 | - | ||
| 79 | - // 3. 强制截断:确保最小值为 0.0,最大值为 1.0 | ||
| 80 | - // Math.max 防止出现负数,Math.min 防止出现 > 1.0 的数 | ||
| 81 | - double clampedScore = Math.max(0.0, Math.min(1.0, normalizedScore)); | ||
| 82 | - | ||
| 83 | - // 4. 四舍五入到 4 位小数 | ||
| 84 | - // 原理:乘以 10000 -> 四舍五入取整 -> 除以 10000 | ||
| 85 | - // clampedScore = Math.round(clampedScore * 10000.0) / 10000.0; | ||
| 86 | - return clampedScore; | ||
| 87 | - """, | ||
| 88 | - "params": { | ||
| 89 | - "query_vector": embedding.tolist() if isinstance(embedding, numpy.ndarray) else list(embedding) | ||
| 90 | - } | ||
| 91 | - } | ||
| 92 | - } | ||
| 93 | - } | ||
| 94 | - search_body.update({ | ||
| 95 | - "query": query_body | ||
| 96 | - }) | ||
| 97 | - else: | ||
| 98 | - search_body.update({ | ||
| 99 | - "knn": { | ||
| 100 | - "field": "embedding", | ||
| 101 | - "query_vector": embedding.tolist() if isinstance(embedding, numpy.ndarray) else list(embedding), | ||
| 102 | - "k": from_ + size, | ||
| 103 | - "num_candidates": (from_ + size) * 20, | ||
| 104 | - "filter": condition_query, | ||
| 105 | - } | ||
| 106 | - }) | ||
| 107 | - else: | ||
| 108 | - query_body = condition_query | ||
| 109 | - search_body.update({ | ||
| 110 | - "query": query_body | ||
| 111 | - }) | ||
| 112 | - | ||
| 113 | - logger.info(f'index {tb_name} search body: {json.dumps(search_body, ensure_ascii=False, indent=4)}') | ||
| 114 | - resp = await self.es_client.search(index=tb_name, body=search_body) | ||
| 115 | - logger.info(f'index {tb_name} search response: {resp}') | ||
| 116 | - | ||
| 117 | - body = resp.body | ||
| 118 | - hits = body.get('hits', {}).get('hits', []) | ||
| 119 | - result_data = [ | ||
| 120 | - { | ||
| 121 | - 'id': hit['_id'], | ||
| 122 | - 'score': round(hit['_score'], 4), | ||
| 123 | - 'kwargs': hit['_source'], | ||
| 124 | - } | ||
| 125 | - for hit in hits | ||
| 126 | - ] | ||
| 127 | - return result_data | ||
| 128 | - | ||
| 129 | - async def delete_by_pks(self, tb_name: str, pks: typing.List[str]): | ||
| 130 | - query_body = { | ||
| 131 | - "query": { | ||
| 132 | - "terms": { | ||
| 133 | - "_id": pks | ||
| 134 | - } | ||
| 135 | - } | ||
| 136 | - } | ||
| 137 | - resp = await self.es_client.delete_by_query(index=tb_name, body=query_body) | ||
| 138 | - logger.info(f'index {tb_name} delete_by_query response: {resp}') | ||
| 139 | - | ||
| 140 | - body = resp.body | ||
| 141 | - # return body.get('deleted') | ||
| 142 | - | ||
| 143 | - async def _get_mapping(self, tb_name: str): | ||
| 144 | - resp = await self.es_client.indices.get_mapping(index=tb_name) | ||
| 145 | - logger.info(f'index {tb_name} get_mapping response: {resp}') | ||
| 146 | - return resp.body.get(tb_name, {}) | ||
| 147 | - | ||
| 148 | - async def _tb_exist(self, tb_name, mapping=None): | ||
| 149 | - resp = await self.es_client.indices.exists(index=tb_name) | ||
| 150 | - result = resp.body | ||
| 151 | - if result and mapping is not None: | ||
| 152 | - tb_mapping = await self._get_mapping(tb_name) | ||
| 153 | - exist_properties = tb_mapping.get('mappings', {}).get('properties', {}) | ||
| 154 | - | ||
| 155 | - expect_properties = mapping.get('mappings', {}).get('properties', {}) | ||
| 156 | - if exist_properties and expect_properties: | ||
| 157 | - shared_keys = set(exist_properties.keys()).intersection(set(expect_properties.keys())) | ||
| 158 | - for k in shared_keys or []: | ||
| 159 | - if exist_properties[k]['type'] != expect_properties[k]['type']: | ||
| 160 | - raise Exception( | ||
| 161 | - f'index {tb_name} `{k}` type not match, expect: {expect_properties[k]["type"]}, actual: {exist_properties[k]["type"]}') | ||
| 162 | - else: | ||
| 163 | - if exist_properties[k]['type'] == 'dense_vector': | ||
| 164 | - if exist_properties[k].get('dims') != expect_properties[k].get('dims'): | ||
| 165 | - raise Exception( | ||
| 166 | - f'index {tb_name} dims not match, expect: {expect_properties[k].get("dims")}, actual: {exist_properties[k].get("dims")}') | ||
| 167 | - return result | ||
| 168 | - | ||
| 169 | - async def _create_index(self, tb_name, mapping=typing.Optional[dict]) -> bool: | ||
| 170 | - result = await self._tb_exist(tb_name=tb_name, mapping=mapping) | ||
| 171 | - if result: | ||
| 172 | - return True | ||
| 173 | - with self._lock: | ||
| 174 | - result = await self._tb_exist(tb_name=tb_name, mapping=mapping) | ||
| 175 | - if result: | ||
| 176 | - return True | ||
| 177 | - resp = await self.es_client.indices.create(index=tb_name, body=mapping) | ||
| 178 | - logger.info(f'index {tb_name} create response: {resp}') | ||
| 179 | - return resp.body.get('acknowledged') is True | ||
| 180 | - | ||
| 181 | - async def upsert(self, tb_name, id, embedding, params: dict): | ||
| 182 | - doc = { | ||
| 183 | - 'embedding': embedding, | ||
| 184 | - **params | ||
| 185 | - } | ||
| 186 | - | ||
| 187 | - auto_create_index = os.getenv('AUTO_CREATE_INDEX', 'true').lower().strip() | ||
| 188 | - if auto_create_index == 'true': | ||
| 189 | - mapping = generate_mapping(doc) | ||
| 190 | - # logger.info(f'Possible mapping: {json.dumps(mapping, indent=4)}') | ||
| 191 | - result = await self._create_index(tb_name=tb_name, mapping=mapping) | ||
| 192 | - | ||
| 193 | - if isinstance(embedding, numpy.ndarray): | ||
| 194 | - embedding = embedding.tolist() | ||
| 195 | - # elif isinstance(embedding, typing.Iterable): | ||
| 196 | - # if isinstance(embedding, typing.Mapping): | ||
| 197 | - # pass | ||
| 198 | - # else: | ||
| 199 | - # embedding = list(embedding) | ||
| 200 | - doc['embedding'] = embedding | ||
| 201 | - | ||
| 202 | - resp = await self.es_client.index(index=tb_name, id=id, body=doc) | ||
| 203 | - logger.info(f'index {tb_name} index response: {resp}') | ||
| 204 | - | ||
| 205 | - body = resp.body | ||
| 206 | - assert body.get('result') in ['created', 'updated'], f'数据插入失败, id: {id}' | ||
| 207 | - return body.get('_id') | ||
| 208 | - | ||
| 209 | - | ||
| 210 | -def get_filter_clause(k, v, opt, type_property): | ||
| 211 | - property_type = type_property.get('type') | ||
| 212 | - if property_type == 'text': | ||
| 213 | - property_fields = type_property.get('fields') or {} | ||
| 214 | - if 'keyword' in property_fields: | ||
| 215 | - k = f'{k}.keyword' | ||
| 216 | - | ||
| 217 | - opt = opt.lower() | ||
| 218 | - # "eq", "neq", "lt", "gt", "lte", "gte", "like", "in" | ||
| 219 | - if opt in ['eq', 'in']: | ||
| 220 | - v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 221 | - should_list = [] | ||
| 222 | - if None in v or len(v) == 0: | ||
| 223 | - should_list.append({ | ||
| 224 | - "bool": { | ||
| 225 | - "must_not": [ | ||
| 226 | - { | ||
| 227 | - "exists": { | ||
| 228 | - "field": k | ||
| 229 | - } | ||
| 230 | - } | ||
| 231 | - ] | ||
| 232 | - } | ||
| 233 | - }) | ||
| 234 | - no_null_v = [item for item in v if item is not None] | ||
| 235 | - if len(no_null_v) > 0: | ||
| 236 | - should_list.append({ | ||
| 237 | - "terms": { | ||
| 238 | - k: no_null_v | ||
| 239 | - } | ||
| 240 | - }) | ||
| 241 | - return should_list[0] if len(should_list) == 1 else { | ||
| 242 | - "bool": { | ||
| 243 | - "should": should_list, | ||
| 244 | - "minimum_should_match": 1 | ||
| 245 | - } | ||
| 246 | - } | ||
| 247 | - elif opt == 'neq': | ||
| 248 | - must_list = [] | ||
| 249 | - v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 250 | - | ||
| 251 | - if None in v or len(v) == 0: | ||
| 252 | - must_list.append({ | ||
| 253 | - "bool": { | ||
| 254 | - "must": [ | ||
| 255 | - { | ||
| 256 | - "exists": { | ||
| 257 | - "field": k | ||
| 258 | - } | ||
| 259 | - } | ||
| 260 | - ] | ||
| 261 | - } | ||
| 262 | - }) | ||
| 263 | - no_null_v = [item for item in v if item is not None] | ||
| 264 | - if len(no_null_v) > 0: | ||
| 265 | - must_list.append({ | ||
| 266 | - "bool": { | ||
| 267 | - "must_not": { | ||
| 268 | - "terms": { | ||
| 269 | - k: no_null_v | ||
| 270 | - } | ||
| 271 | - } | ||
| 272 | - } | ||
| 273 | - }) | ||
| 274 | - return must_list[0] if len(must_list) == 1 else { | ||
| 275 | - "bool": { | ||
| 276 | - "must": must_list | ||
| 277 | - } | ||
| 278 | - } | ||
| 279 | - elif opt in ['lt', 'lte', 'gt', 'gte']: | ||
| 280 | - v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 281 | - if len(v) > 0 and v[0] is not None: | ||
| 282 | - return { | ||
| 283 | - "range": { | ||
| 284 | - k: {opt: list(v)[0]} | ||
| 285 | - } | ||
| 286 | - } | ||
| 287 | - else: | ||
| 288 | - return { | ||
| 289 | - "range": { | ||
| 290 | - k: {} | ||
| 291 | - } | ||
| 292 | - } | ||
| 293 | - elif opt == 'like': | ||
| 294 | - v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 295 | - if len(v) > 0 and v[0] is not None: | ||
| 296 | - return { | ||
| 297 | - "wildcard": { | ||
| 298 | - k: f"*{list(v)[0]}*" | ||
| 299 | - } | ||
| 300 | - } | ||
| 301 | - else: | ||
| 302 | - return { | ||
| 303 | - "bool": { | ||
| 304 | - "must_not": [ | ||
| 305 | - { | ||
| 306 | - "exists": { | ||
| 307 | - "field": k | ||
| 308 | - } | ||
| 309 | - } | ||
| 310 | - ] | ||
| 311 | - } | ||
| 312 | - } | ||
| 313 | - else: | ||
| 314 | - raise Exception(f'opt {opt} not support') | ||
| 315 | - | ||
| 316 | - | ||
| 317 | -def generate_mapping(doc): | ||
| 318 | - def _inner(v): | ||
| 319 | - if isinstance(v, numpy.ndarray): | ||
| 320 | - return 'dense_vector' | ||
| 321 | - elif isinstance(v, str): | ||
| 322 | - return 'text' | ||
| 323 | - elif isinstance(v, int): | ||
| 324 | - return 'long' | ||
| 325 | - elif isinstance(v, float): | ||
| 326 | - return 'float' | ||
| 327 | - elif isinstance(v, typing.Mapping): | ||
| 328 | - return None | ||
| 329 | - elif isinstance(v, typing.Collection): | ||
| 330 | - if len(v) == 0: | ||
| 331 | - return None | ||
| 332 | - else: | ||
| 333 | - return _inner(list(v)[0]) | ||
| 334 | - else: | ||
| 335 | - return None | ||
| 336 | - | ||
| 337 | - properties = dict() | ||
| 338 | - for key, value in (doc or {}).items(): | ||
| 339 | - key_type = _inner(value) | ||
| 340 | - if key_type: | ||
| 341 | - properties[key] = { | ||
| 342 | - 'type': key_type, | ||
| 343 | - } | ||
| 344 | - if key_type == 'dense_vector': | ||
| 345 | - properties[key]['dims'] = len(value) | ||
| 346 | - properties[key].update({ | ||
| 347 | - 'dims': len(value), | ||
| 348 | - 'index': True, | ||
| 349 | - 'similarity': 'cosine' | ||
| 350 | - }) | ||
| 351 | - elif key_type == 'text': | ||
| 352 | - properties[key]['fields'] = { | ||
| 353 | - "keyword": { | ||
| 354 | - "type": "keyword", | ||
| 355 | - # "ignore_above": 256 | ||
| 356 | - } | ||
| 357 | - } | ||
| 358 | - es_mapping = { | ||
| 359 | - "settings": { | ||
| 360 | - "number_of_replicas": int(os.getenv('ES_NUMBER_OF_REPLICAS', 0)), | ||
| 361 | - }, | ||
| 362 | - "mappings": { | ||
| 363 | - "properties": properties | ||
| 364 | - } | ||
| 365 | - } | ||
| 366 | - return es_mapping | 1 | +import json |
| 2 | +import logging | ||
| 3 | +import os | ||
| 4 | +import threading | ||
| 5 | +import typing | ||
| 6 | + | ||
| 7 | +from elasticsearch import AsyncElasticsearch | ||
| 8 | +import numpy | ||
| 9 | + | ||
| 10 | +logger = logging.getLogger(__name__) | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +class DataDao: | ||
| 14 | + | ||
| 15 | + def __init__(self, es_client: AsyncElasticsearch): | ||
| 16 | + self.es_client = es_client | ||
| 17 | + | ||
| 18 | + self._lock = threading.RLock() | ||
| 19 | + | ||
| 20 | + async def search(self, tb_name: str, | ||
| 21 | + embedding=typing.Optional[typing.Union[numpy.ndarray, list]], | ||
| 22 | + filters=None, | ||
| 23 | + from_=typing.Optional[int], | ||
| 24 | + size=typing.Optional[int], | ||
| 25 | + ): | ||
| 26 | + search_body = dict() | ||
| 27 | + | ||
| 28 | + from_ = from_ if isinstance(from_, int) and from_ >= 0 else 0 | ||
| 29 | + size = size if isinstance(size, int) and size > 0 else 10 | ||
| 30 | + search_body.update({ | ||
| 31 | + 'from': from_, | ||
| 32 | + 'size': size, | ||
| 33 | + }) | ||
| 34 | + | ||
| 35 | + return_embedding = os.getenv('RETURN_EMBEDDING', 'false').lower().strip() | ||
| 36 | + if return_embedding != 'true': | ||
| 37 | + search_body.update({ | ||
| 38 | + "_source": { | ||
| 39 | + "excludes": ["embedding"] | ||
| 40 | + } | ||
| 41 | + }) | ||
| 42 | + | ||
| 43 | + condition_query = { | ||
| 44 | + "match_all": {} | ||
| 45 | + } | ||
| 46 | + if isinstance(filters, typing.Collection) and len(filters) > 0: | ||
| 47 | + tb_mapping = await self._get_mapping(tb_name) | ||
| 48 | + exist_properties = tb_mapping.get('mappings', {}).get('properties', {}) | ||
| 49 | + | ||
| 50 | + clauses = [] | ||
| 51 | + for filter in filters: | ||
| 52 | + k = filter.name | ||
| 53 | + v = filter.value | ||
| 54 | + opt = filter.opt | ||
| 55 | + if k not in exist_properties: | ||
| 56 | + continue | ||
| 57 | + clause = get_filter_clause(k, v, opt, exist_properties[k]) | ||
| 58 | + if clause: | ||
| 59 | + clauses.append(clause) | ||
| 60 | + condition_query = { | ||
| 61 | + "bool": { | ||
| 62 | + "filter": clauses | ||
| 63 | + } | ||
| 64 | + } | ||
| 65 | + if embedding is not None: | ||
| 66 | + use_knn = os.getenv('USE_KNN', 'false').lower().strip() | ||
| 67 | + if use_knn != 'true': | ||
| 68 | + query_body = { | ||
| 69 | + "script_score": { | ||
| 70 | + "query": condition_query, | ||
| 71 | + "script": { | ||
| 72 | + "source": """ | ||
| 73 | + // 1. 计算原始分数 (范围 [0, 2]) | ||
| 74 | + double rawScore = cosineSimilarity(params.query_vector, 'embedding') + 1.0; | ||
| 75 | + | ||
| 76 | + // 2. 归一化到 [0, 1] | ||
| 77 | + double normalizedScore = rawScore / 2.0; | ||
| 78 | + | ||
| 79 | + // 3. 强制截断:确保最小值为 0.0,最大值为 1.0 | ||
| 80 | + // Math.max 防止出现负数,Math.min 防止出现 > 1.0 的数 | ||
| 81 | + double clampedScore = Math.max(0.0, Math.min(1.0, normalizedScore)); | ||
| 82 | + | ||
| 83 | + // 4. 四舍五入到 4 位小数 | ||
| 84 | + // 原理:乘以 10000 -> 四舍五入取整 -> 除以 10000 | ||
| 85 | + // clampedScore = Math.round(clampedScore * 10000.0) / 10000.0; | ||
| 86 | + return clampedScore; | ||
| 87 | + """, | ||
| 88 | + "params": { | ||
| 89 | + "query_vector": embedding.tolist() if isinstance(embedding, numpy.ndarray) else list(embedding) | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | + search_body.update({ | ||
| 95 | + "query": query_body | ||
| 96 | + }) | ||
| 97 | + else: | ||
| 98 | + search_body.update({ | ||
| 99 | + "knn": { | ||
| 100 | + "field": "embedding", | ||
| 101 | + "query_vector": embedding.tolist() if isinstance(embedding, numpy.ndarray) else list(embedding), | ||
| 102 | + "k": from_ + size, | ||
| 103 | + "num_candidates": (from_ + size) * 20, | ||
| 104 | + "filter": condition_query, | ||
| 105 | + } | ||
| 106 | + }) | ||
| 107 | + else: | ||
| 108 | + query_body = condition_query | ||
| 109 | + search_body.update({ | ||
| 110 | + "query": query_body | ||
| 111 | + }) | ||
| 112 | + | ||
| 113 | + logger.info(f'index {tb_name} search body: {json.dumps(search_body, ensure_ascii=False, indent=4)}') | ||
| 114 | + resp = await self.es_client.search(index=tb_name, body=search_body) | ||
| 115 | + logger.info(f'index {tb_name} search response: {resp}') | ||
| 116 | + | ||
| 117 | + body = resp.body | ||
| 118 | + hits = body.get('hits', {}).get('hits', []) | ||
| 119 | + result_data = [ | ||
| 120 | + { | ||
| 121 | + 'id': hit['_id'], | ||
| 122 | + 'score': round(hit['_score'], 4), | ||
| 123 | + 'kwargs': hit['_source'], | ||
| 124 | + } | ||
| 125 | + for hit in hits | ||
| 126 | + ] | ||
| 127 | + return result_data | ||
| 128 | + | ||
| 129 | + async def delete_by_pks(self, tb_name: str, pks: typing.List[str]): | ||
| 130 | + query_body = { | ||
| 131 | + "query": { | ||
| 132 | + "terms": { | ||
| 133 | + "_id": pks | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + resp = await self.es_client.delete_by_query(index=tb_name, body=query_body) | ||
| 138 | + logger.info(f'index {tb_name} delete_by_query response: {resp}') | ||
| 139 | + | ||
| 140 | + body = resp.body | ||
| 141 | + # return body.get('deleted') | ||
| 142 | + | ||
| 143 | + async def _get_mapping(self, tb_name: str): | ||
| 144 | + resp = await self.es_client.indices.get_mapping(index=tb_name) | ||
| 145 | + logger.info(f'index {tb_name} get_mapping response: {resp}') | ||
| 146 | + return resp.body.get(tb_name, {}) | ||
| 147 | + | ||
| 148 | + async def _tb_exist(self, tb_name, mapping=None): | ||
| 149 | + resp = await self.es_client.indices.exists(index=tb_name) | ||
| 150 | + result = resp.body | ||
| 151 | + if result and mapping is not None: | ||
| 152 | + tb_mapping = await self._get_mapping(tb_name) | ||
| 153 | + exist_properties = tb_mapping.get('mappings', {}).get('properties', {}) | ||
| 154 | + | ||
| 155 | + expect_properties = mapping.get('mappings', {}).get('properties', {}) | ||
| 156 | + if exist_properties and expect_properties: | ||
| 157 | + shared_keys = set(exist_properties.keys()).intersection(set(expect_properties.keys())) | ||
| 158 | + for k in shared_keys or []: | ||
| 159 | + if exist_properties[k]['type'] != expect_properties[k]['type']: | ||
| 160 | + raise Exception( | ||
| 161 | + f'index {tb_name} `{k}` type not match, expect: {expect_properties[k]["type"]}, actual: {exist_properties[k]["type"]}') | ||
| 162 | + else: | ||
| 163 | + if exist_properties[k]['type'] == 'dense_vector': | ||
| 164 | + if exist_properties[k].get('dims') != expect_properties[k].get('dims'): | ||
| 165 | + raise Exception( | ||
| 166 | + f'index {tb_name} dims not match, expect: {expect_properties[k].get("dims")}, actual: {exist_properties[k].get("dims")}') | ||
| 167 | + return result | ||
| 168 | + | ||
| 169 | + async def _create_index(self, tb_name, mapping=typing.Optional[dict]) -> bool: | ||
| 170 | + result = await self._tb_exist(tb_name=tb_name, mapping=mapping) | ||
| 171 | + if result: | ||
| 172 | + return True | ||
| 173 | + with self._lock: | ||
| 174 | + result = await self._tb_exist(tb_name=tb_name, mapping=mapping) | ||
| 175 | + if result: | ||
| 176 | + return True | ||
| 177 | + resp = await self.es_client.indices.create(index=tb_name, body=mapping) | ||
| 178 | + logger.info(f'index {tb_name} create response: {resp}') | ||
| 179 | + return resp.body.get('acknowledged') is True | ||
| 180 | + | ||
| 181 | + async def upsert(self, tb_name, id, embedding, params: dict): | ||
| 182 | + doc = { | ||
| 183 | + 'embedding': embedding, | ||
| 184 | + **params | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + auto_create_index = os.getenv('AUTO_CREATE_INDEX', 'true').lower().strip() | ||
| 188 | + if auto_create_index == 'true': | ||
| 189 | + mapping = generate_mapping(doc) | ||
| 190 | + # logger.info(f'Possible mapping: {json.dumps(mapping, indent=4)}') | ||
| 191 | + result = await self._create_index(tb_name=tb_name, mapping=mapping) | ||
| 192 | + | ||
| 193 | + if isinstance(embedding, numpy.ndarray): | ||
| 194 | + embedding = embedding.tolist() | ||
| 195 | + # elif isinstance(embedding, typing.Iterable): | ||
| 196 | + # if isinstance(embedding, typing.Mapping): | ||
| 197 | + # pass | ||
| 198 | + # else: | ||
| 199 | + # embedding = list(embedding) | ||
| 200 | + doc['embedding'] = embedding | ||
| 201 | + | ||
| 202 | + resp = await self.es_client.index(index=tb_name, id=id, body=doc) | ||
| 203 | + logger.info(f'index {tb_name} index response: {resp}') | ||
| 204 | + | ||
| 205 | + body = resp.body | ||
| 206 | + assert body.get('result') in ['created', 'updated'], f'数据插入失败, id: {id}' | ||
| 207 | + return body.get('_id') | ||
| 208 | + | ||
| 209 | + | ||
| 210 | +def get_filter_clause(k, v, opt, type_property): | ||
| 211 | + property_type = type_property.get('type') | ||
| 212 | + if property_type == 'text': | ||
| 213 | + property_fields = type_property.get('fields') or {} | ||
| 214 | + if 'keyword' in property_fields: | ||
| 215 | + k = f'{k}.keyword' | ||
| 216 | + | ||
| 217 | + opt = opt.lower() | ||
| 218 | + # "eq", "neq", "lt", "gt", "lte", "gte", "like", "in" | ||
| 219 | + if opt in ['eq', 'in']: | ||
| 220 | + v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 221 | + should_list = [] | ||
| 222 | + if None in v or len(v) == 0: | ||
| 223 | + should_list.append({ | ||
| 224 | + "bool": { | ||
| 225 | + "must_not": [ | ||
| 226 | + { | ||
| 227 | + "exists": { | ||
| 228 | + "field": k | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + ] | ||
| 232 | + } | ||
| 233 | + }) | ||
| 234 | + no_null_v = [item for item in v if item is not None] | ||
| 235 | + if len(no_null_v) > 0: | ||
| 236 | + should_list.append({ | ||
| 237 | + "terms": { | ||
| 238 | + k: no_null_v | ||
| 239 | + } | ||
| 240 | + }) | ||
| 241 | + return should_list[0] if len(should_list) == 1 else { | ||
| 242 | + "bool": { | ||
| 243 | + "should": should_list, | ||
| 244 | + "minimum_should_match": 1 | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | + elif opt == 'neq': | ||
| 248 | + must_list = [] | ||
| 249 | + v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 250 | + | ||
| 251 | + if None in v or len(v) == 0: | ||
| 252 | + must_list.append({ | ||
| 253 | + "bool": { | ||
| 254 | + "must": [ | ||
| 255 | + { | ||
| 256 | + "exists": { | ||
| 257 | + "field": k | ||
| 258 | + } | ||
| 259 | + } | ||
| 260 | + ] | ||
| 261 | + } | ||
| 262 | + }) | ||
| 263 | + no_null_v = [item for item in v if item is not None] | ||
| 264 | + if len(no_null_v) > 0: | ||
| 265 | + must_list.append({ | ||
| 266 | + "bool": { | ||
| 267 | + "must_not": { | ||
| 268 | + "terms": { | ||
| 269 | + k: no_null_v | ||
| 270 | + } | ||
| 271 | + } | ||
| 272 | + } | ||
| 273 | + }) | ||
| 274 | + return must_list[0] if len(must_list) == 1 else { | ||
| 275 | + "bool": { | ||
| 276 | + "must": must_list | ||
| 277 | + } | ||
| 278 | + } | ||
| 279 | + elif opt in ['lt', 'lte', 'gt', 'gte']: | ||
| 280 | + v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 281 | + if len(v) > 0 and v[0] is not None: | ||
| 282 | + return { | ||
| 283 | + "range": { | ||
| 284 | + k: {opt: list(v)[0]} | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | + else: | ||
| 288 | + return { | ||
| 289 | + "range": { | ||
| 290 | + k: {} | ||
| 291 | + } | ||
| 292 | + } | ||
| 293 | + elif opt == 'like': | ||
| 294 | + v = list(v) if isinstance(v, typing.Collection) else [v] | ||
| 295 | + if len(v) > 0 and v[0] is not None: | ||
| 296 | + return { | ||
| 297 | + "wildcard": { | ||
| 298 | + k: f"*{list(v)[0]}*" | ||
| 299 | + } | ||
| 300 | + } | ||
| 301 | + else: | ||
| 302 | + return { | ||
| 303 | + "bool": { | ||
| 304 | + "must_not": [ | ||
| 305 | + { | ||
| 306 | + "exists": { | ||
| 307 | + "field": k | ||
| 308 | + } | ||
| 309 | + } | ||
| 310 | + ] | ||
| 311 | + } | ||
| 312 | + } | ||
| 313 | + else: | ||
| 314 | + raise Exception(f'opt {opt} not support') | ||
| 315 | + | ||
| 316 | + | ||
| 317 | +def generate_mapping(doc): | ||
| 318 | + def _inner(v): | ||
| 319 | + if isinstance(v, numpy.ndarray): | ||
| 320 | + return 'dense_vector' | ||
| 321 | + elif isinstance(v, str): | ||
| 322 | + return 'text' | ||
| 323 | + elif isinstance(v, int): | ||
| 324 | + return 'long' | ||
| 325 | + elif isinstance(v, float): | ||
| 326 | + return 'float' | ||
| 327 | + elif isinstance(v, typing.Mapping): | ||
| 328 | + return None | ||
| 329 | + elif isinstance(v, typing.Collection): | ||
| 330 | + if len(v) == 0: | ||
| 331 | + return None | ||
| 332 | + else: | ||
| 333 | + return _inner(list(v)[0]) | ||
| 334 | + else: | ||
| 335 | + return None | ||
| 336 | + | ||
| 337 | + properties = dict() | ||
| 338 | + for key, value in (doc or {}).items(): | ||
| 339 | + key_type = _inner(value) | ||
| 340 | + if key_type: | ||
| 341 | + properties[key] = { | ||
| 342 | + 'type': key_type, | ||
| 343 | + } | ||
| 344 | + if key_type == 'dense_vector': | ||
| 345 | + properties[key]['dims'] = len(value) | ||
| 346 | + properties[key].update({ | ||
| 347 | + 'dims': len(value), | ||
| 348 | + 'index': True, | ||
| 349 | + 'similarity': 'cosine' | ||
| 350 | + }) | ||
| 351 | + elif key_type == 'text': | ||
| 352 | + properties[key]['fields'] = { | ||
| 353 | + "keyword": { | ||
| 354 | + "type": "keyword", | ||
| 355 | + # "ignore_above": 256 | ||
| 356 | + } | ||
| 357 | + } | ||
| 358 | + es_mapping = { | ||
| 359 | + "settings": { | ||
| 360 | + "number_of_replicas": int(os.getenv('ES_NUMBER_OF_REPLICAS', 0)), | ||
| 361 | + }, | ||
| 362 | + "mappings": { | ||
| 363 | + "properties": properties | ||
| 364 | + } | ||
| 365 | + } | ||
| 366 | + return es_mapping |
| 1 | -"""Elasticsearch向量数据库客户端实现.""" | ||
| 2 | - | ||
| 3 | -from typing import Any, Optional | ||
| 4 | - | ||
| 5 | -from embedding_sever.db.base import VectorDBClient | ||
| 6 | - | ||
| 7 | - | ||
| 8 | -class ElasticsearchClient(VectorDBClient): | ||
| 9 | - """Elasticsearch向量数据库客户端. | ||
| 10 | - | ||
| 11 | - 使用Elasticsearch的dense_vector类型存储向量,支持向量相似度搜索。 | ||
| 12 | - """ | ||
| 13 | - | ||
| 14 | - def __init__(self, es_url: Optional[str] = None): | ||
| 15 | - """初始化ES客户端. | ||
| 16 | - | ||
| 17 | - Args: | ||
| 18 | - es_url: Elasticsearch连接URL,默认从配置读取 | ||
| 19 | - """ | ||
| 20 | - self.es_url = es_url | ||
| 21 | - self._client: Optional[Any] = None | ||
| 22 | - | ||
| 23 | - async def connect(self) -> None: | ||
| 24 | - """建立ES连接.""" | ||
| 25 | - from elasticsearch import AsyncElasticsearch | ||
| 26 | - | ||
| 27 | - self._client = AsyncElasticsearch([self.es_url]) | ||
| 28 | - # 验证连接 | ||
| 29 | - await self._client.ping() | ||
| 30 | - | ||
| 31 | - async def disconnect(self) -> None: | ||
| 32 | - """断开ES连接.""" | ||
| 33 | - if self._client: | ||
| 34 | - await self._client.close() | ||
| 35 | - self._client = None | ||
| 36 | - | ||
| 37 | - async def health_check(self) -> bool: | ||
| 38 | - """检查ES连接健康状态.""" | ||
| 39 | - if not self._client: | ||
| 40 | - return False | ||
| 41 | - try: | ||
| 42 | - return await self._client.ping() | ||
| 43 | - except Exception: | ||
| 44 | - return False | ||
| 45 | -_es_client: Optional[ElasticsearchClient] = None | ||
| 46 | - | ||
| 47 | - | ||
| 48 | -def get_es_client() -> ElasticsearchClient: | ||
| 49 | - """获取全局ES客户端实例(单例模式). | ||
| 50 | - | ||
| 51 | - Returns: | ||
| 52 | - ElasticsearchClient: ES客户端实例 | ||
| 53 | - """ | ||
| 54 | - global _es_client | ||
| 55 | - if _es_client is None: | ||
| 56 | - _es_client = ElasticsearchClient() | ||
| 57 | - return _es_client | 1 | +"""Elasticsearch向量数据库客户端实现.""" |
| 2 | + | ||
| 3 | +from typing import Any, Optional | ||
| 4 | + | ||
| 5 | +from embedding_sever.db.base import VectorDBClient | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +class ElasticsearchClient(VectorDBClient): | ||
| 9 | + """Elasticsearch向量数据库客户端. | ||
| 10 | + | ||
| 11 | + 使用Elasticsearch的dense_vector类型存储向量,支持向量相似度搜索。 | ||
| 12 | + """ | ||
| 13 | + | ||
| 14 | + def __init__(self, es_url: Optional[str] = None): | ||
| 15 | + """初始化ES客户端. | ||
| 16 | + | ||
| 17 | + Args: | ||
| 18 | + es_url: Elasticsearch连接URL,默认从配置读取 | ||
| 19 | + """ | ||
| 20 | + self.es_url = es_url | ||
| 21 | + self._client: Optional[Any] = None | ||
| 22 | + | ||
| 23 | + async def connect(self) -> None: | ||
| 24 | + """建立ES连接.""" | ||
| 25 | + from elasticsearch import AsyncElasticsearch | ||
| 26 | + | ||
| 27 | + self._client = AsyncElasticsearch([self.es_url]) | ||
| 28 | + # 验证连接 | ||
| 29 | + await self._client.ping() | ||
| 30 | + | ||
| 31 | + async def disconnect(self) -> None: | ||
| 32 | + """断开ES连接.""" | ||
| 33 | + if self._client: | ||
| 34 | + await self._client.close() | ||
| 35 | + self._client = None | ||
| 36 | + | ||
| 37 | + async def health_check(self) -> bool: | ||
| 38 | + """检查ES连接健康状态.""" | ||
| 39 | + if not self._client: | ||
| 40 | + return False | ||
| 41 | + try: | ||
| 42 | + return await self._client.ping() | ||
| 43 | + except Exception: | ||
| 44 | + return False | ||
| 45 | +_es_client: Optional[ElasticsearchClient] = None | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +def get_es_client() -> ElasticsearchClient: | ||
| 49 | + """获取全局ES客户端实例(单例模式). | ||
| 50 | + | ||
| 51 | + Returns: | ||
| 52 | + ElasticsearchClient: ES客户端实例 | ||
| 53 | + """ | ||
| 54 | + global _es_client | ||
| 55 | + if _es_client is None: | ||
| 56 | + _es_client = ElasticsearchClient() | ||
| 57 | + return _es_client |
| 1 | -from .base import TableInit | ||
| 2 | -from . import es_client | ||
| 3 | - | ||
| 4 | - | ||
| 5 | -class ESTableInit(TableInit): | ||
| 6 | - | ||
| 7 | - def __init__(self): | ||
| 8 | - self.es_client = es_client | ||
| 9 | - | ||
| 10 | - async def create_table(self, name, embedding_version, embedding_dim) -> None: | ||
| 11 | - pass | 1 | +from .base import TableInit |
| 2 | +from . import es_client | ||
| 3 | + | ||
| 4 | + | ||
| 5 | +class ESTableInit(TableInit): | ||
| 6 | + | ||
| 7 | + def __init__(self): | ||
| 8 | + self.es_client = es_client | ||
| 9 | + | ||
| 10 | + async def create_table(self, name, embedding_version, embedding_dim) -> None: | ||
| 11 | + pass |
| 1 | -"""PostgreSQL向量数据库客户端实现.""" | ||
| 2 | - | ||
| 3 | -from typing import Any, Optional | ||
| 4 | - | ||
| 5 | -from embedding_sever.db.base import VectorDBClient | ||
| 6 | - | ||
| 7 | - | ||
| 8 | -class PostgresClient(VectorDBClient): | ||
| 9 | - """PostgreSQL向量数据库客户端. | ||
| 10 | - | ||
| 11 | - 使用pgvector扩展存储向量,支持向量相似度搜索。 | ||
| 12 | - 需要安装pgvector扩展: CREATE EXTENSION IF NOT EXISTS vector; | ||
| 13 | - """ | ||
| 14 | - | ||
| 15 | - def __init__(self, pg_url: Optional[str] = None): | ||
| 16 | - """初始化PG客户端. | ||
| 17 | - | ||
| 18 | - Args: | ||
| 19 | - pg_url: PostgreSQL连接URL,默认从配置读取 | ||
| 20 | - """ | ||
| 21 | - self.pg_url = pg_url | ||
| 22 | - self._pool: Optional[Any] = None | ||
| 23 | - | ||
| 24 | - async def connect(self) -> None: | ||
| 25 | - """建立PG连接池.""" | ||
| 26 | - from psycopg_pool import AsyncConnectionPool | ||
| 27 | - | ||
| 28 | - self._pool = AsyncConnectionPool( | ||
| 29 | - conninfo=self.pg_url, | ||
| 30 | - min_size=1, | ||
| 31 | - max_size=10, | ||
| 32 | - ) | ||
| 33 | - | ||
| 34 | - # 验证连接并启用pgvector | ||
| 35 | - async with self._pool.connection() as conn: | ||
| 36 | - await conn.execute("CREATE EXTENSION IF NOT EXISTS vector") | ||
| 37 | - await conn.commit() | ||
| 38 | - | ||
| 39 | - async def disconnect(self) -> None: | ||
| 40 | - """断开PG连接.""" | ||
| 41 | - if self._pool: | ||
| 42 | - await self._pool.close() | ||
| 43 | - self._pool = None | ||
| 44 | - | ||
| 45 | - async def health_check(self) -> bool: | ||
| 46 | - """检查PG连接健康状态.""" | ||
| 47 | - if not self._pool: | ||
| 48 | - return False | ||
| 49 | - try: | ||
| 50 | - async with self._pool.connection() as conn: | ||
| 51 | - cursor = await conn.execute("SELECT 1") | ||
| 52 | - result = await cursor.fetchone() | ||
| 53 | - return result is not None and result[0] == 1 | ||
| 54 | - except Exception: | ||
| 55 | - return False | ||
| 56 | - | ||
| 57 | - | ||
| 58 | -# 全局PG客户端实例 | ||
| 59 | -_pg_client: Optional[PostgresClient] = None | ||
| 60 | - | ||
| 61 | - | ||
| 62 | -def get_pg_client() -> PostgresClient: | ||
| 63 | - """获取全局PG客户端实例(单例模式). | ||
| 64 | - | ||
| 65 | - Returns: | ||
| 66 | - PostgresClient: PG客户端实例 | ||
| 67 | - """ | ||
| 68 | - global _pg_client | ||
| 69 | - if _pg_client is None: | ||
| 70 | - _pg_client = PostgresClient() | ||
| 71 | - return _pg_client | 1 | +"""PostgreSQL向量数据库客户端实现.""" |
| 2 | + | ||
| 3 | +from typing import Any, Optional | ||
| 4 | + | ||
| 5 | +from embedding_sever.db.base import VectorDBClient | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +class PostgresClient(VectorDBClient): | ||
| 9 | + """PostgreSQL向量数据库客户端. | ||
| 10 | + | ||
| 11 | + 使用pgvector扩展存储向量,支持向量相似度搜索。 | ||
| 12 | + 需要安装pgvector扩展: CREATE EXTENSION IF NOT EXISTS vector; | ||
| 13 | + """ | ||
| 14 | + | ||
| 15 | + def __init__(self, pg_url: Optional[str] = None): | ||
| 16 | + """初始化PG客户端. | ||
| 17 | + | ||
| 18 | + Args: | ||
| 19 | + pg_url: PostgreSQL连接URL,默认从配置读取 | ||
| 20 | + """ | ||
| 21 | + self.pg_url = pg_url | ||
| 22 | + self._pool: Optional[Any] = None | ||
| 23 | + | ||
| 24 | + async def connect(self) -> None: | ||
| 25 | + """建立PG连接池.""" | ||
| 26 | + from psycopg_pool import AsyncConnectionPool | ||
| 27 | + | ||
| 28 | + self._pool = AsyncConnectionPool( | ||
| 29 | + conninfo=self.pg_url, | ||
| 30 | + min_size=1, | ||
| 31 | + max_size=10, | ||
| 32 | + ) | ||
| 33 | + | ||
| 34 | + # 验证连接并启用pgvector | ||
| 35 | + async with self._pool.connection() as conn: | ||
| 36 | + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector") | ||
| 37 | + await conn.commit() | ||
| 38 | + | ||
| 39 | + async def disconnect(self) -> None: | ||
| 40 | + """断开PG连接.""" | ||
| 41 | + if self._pool: | ||
| 42 | + await self._pool.close() | ||
| 43 | + self._pool = None | ||
| 44 | + | ||
| 45 | + async def health_check(self) -> bool: | ||
| 46 | + """检查PG连接健康状态.""" | ||
| 47 | + if not self._pool: | ||
| 48 | + return False | ||
| 49 | + try: | ||
| 50 | + async with self._pool.connection() as conn: | ||
| 51 | + cursor = await conn.execute("SELECT 1") | ||
| 52 | + result = await cursor.fetchone() | ||
| 53 | + return result is not None and result[0] == 1 | ||
| 54 | + except Exception: | ||
| 55 | + return False | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +# 全局PG客户端实例 | ||
| 59 | +_pg_client: Optional[PostgresClient] = None | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def get_pg_client() -> PostgresClient: | ||
| 63 | + """获取全局PG客户端实例(单例模式). | ||
| 64 | + | ||
| 65 | + Returns: | ||
| 66 | + PostgresClient: PG客户端实例 | ||
| 67 | + """ | ||
| 68 | + global _pg_client | ||
| 69 | + if _pg_client is None: | ||
| 70 | + _pg_client = PostgresClient() | ||
| 71 | + return _pg_client |
| 1 | -import os | ||
| 2 | -from pathlib import Path | ||
| 3 | - | ||
| 4 | -if not os.environ.get("PROJECT_ROOT"): | ||
| 5 | - os.environ['PROJECT_ROOT'] = Path(__file__).parent.absolute().as_posix() | ||
| 6 | -from aabd.base.patched_logging import init_logging, get_logger | ||
| 7 | - | ||
| 8 | -init_logging() | ||
| 9 | -logger = get_logger(__name__) | ||
| 10 | - | ||
| 11 | -from contextlib import asynccontextmanager | ||
| 12 | -from typing import AsyncGenerator | ||
| 13 | -from fastapi import FastAPI | ||
| 14 | -from embedding_sever.api.http_config import config_fastapi | ||
| 15 | -from embedding_sever.config import settings | ||
| 16 | -from embedding_sever.db import connect as db_client_connect, disconnect as db_client_disconnect | ||
| 17 | - | ||
| 18 | - | ||
| 19 | -@asynccontextmanager | ||
| 20 | -async def lifespan(fastapi_app: FastAPI) -> AsyncGenerator[None, None]: | ||
| 21 | - """应用生命周期管理.""" | ||
| 22 | - # 启动时建立数据库连接 | ||
| 23 | - try: | ||
| 24 | - await db_client_connect() | ||
| 25 | - logger.info(f"✓ database connected") | ||
| 26 | - except Exception as e: | ||
| 27 | - logger.error(f"✗ Failed to connect to database: {e}") | ||
| 28 | - raise | ||
| 29 | - | ||
| 30 | - yield | ||
| 31 | - | ||
| 32 | - # 关闭时断开数据库连接 | ||
| 33 | - try: | ||
| 34 | - await db_client_disconnect() | ||
| 35 | - logger.info(f"✓ database disconnected") | ||
| 36 | - except Exception as e: | ||
| 37 | - logger.error(f"✗ Error disconnecting from database: {e}") | ||
| 38 | - | ||
| 39 | - | ||
| 40 | -# 创建应用实例 | ||
| 41 | -app = FastAPI( | ||
| 42 | - title=settings.app_name, | ||
| 43 | - version=settings.app_version, | ||
| 44 | - description="基于向量的CRUD服务,支持多种Embedding场景", | ||
| 45 | - lifespan=lifespan, | ||
| 46 | - docs_url="/docs", | ||
| 47 | - redoc_url="/redoc", | ||
| 48 | -) | ||
| 49 | -config_fastapi(app) | ||
| 50 | - | ||
| 51 | -def main(): | ||
| 52 | - """应用入口函数.""" | ||
| 53 | - import uvicorn | ||
| 54 | - | ||
| 55 | - logger.info(f'docs_url: http://127.0.0.1:{settings.port}/docs') | ||
| 56 | - uvicorn.run( | ||
| 57 | - app, | ||
| 58 | - host='0.0.0.0', | ||
| 59 | - port=settings.port, | ||
| 60 | - reload=settings.debug, | ||
| 61 | - log_config=None | ||
| 62 | - ) | ||
| 63 | - | ||
| 64 | - | ||
| 65 | -if __name__ == "__main__": | ||
| 66 | - main() | 1 | +import os |
| 2 | +from pathlib import Path | ||
| 3 | + | ||
| 4 | +if not os.environ.get("PROJECT_ROOT"): | ||
| 5 | + os.environ['PROJECT_ROOT'] = Path(__file__).parent.absolute().as_posix() | ||
| 6 | +from aabd.base.patched_logging import init_logging, get_logger | ||
| 7 | + | ||
| 8 | +init_logging() | ||
| 9 | +logger = get_logger(__name__) | ||
| 10 | + | ||
| 11 | +from contextlib import asynccontextmanager | ||
| 12 | +from typing import AsyncGenerator | ||
| 13 | +from fastapi import FastAPI | ||
| 14 | +from embedding_sever.api.http_config import config_fastapi | ||
| 15 | +from embedding_sever.config import settings | ||
| 16 | +from embedding_sever.db import connect as db_client_connect, disconnect as db_client_disconnect | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +@asynccontextmanager | ||
| 20 | +async def lifespan(fastapi_app: FastAPI) -> AsyncGenerator[None, None]: | ||
| 21 | + """应用生命周期管理.""" | ||
| 22 | + # 启动时建立数据库连接 | ||
| 23 | + try: | ||
| 24 | + await db_client_connect() | ||
| 25 | + logger.info(f"✓ database connected") | ||
| 26 | + except Exception as e: | ||
| 27 | + logger.error(f"✗ Failed to connect to database: {e}") | ||
| 28 | + raise | ||
| 29 | + | ||
| 30 | + yield | ||
| 31 | + | ||
| 32 | + # 关闭时断开数据库连接 | ||
| 33 | + try: | ||
| 34 | + await db_client_disconnect() | ||
| 35 | + logger.info(f"✓ database disconnected") | ||
| 36 | + except Exception as e: | ||
| 37 | + logger.error(f"✗ Error disconnecting from database: {e}") | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +# 创建应用实例 | ||
| 41 | +app = FastAPI( | ||
| 42 | + title=settings.app_name, | ||
| 43 | + version=settings.app_version, | ||
| 44 | + description="基于向量的CRUD服务,支持多种Embedding场景", | ||
| 45 | + lifespan=lifespan, | ||
| 46 | + docs_url="/docs", | ||
| 47 | + redoc_url="/redoc", | ||
| 48 | +) | ||
| 49 | +config_fastapi(app) | ||
| 50 | + | ||
| 51 | +def main(): | ||
| 52 | + """应用入口函数.""" | ||
| 53 | + import uvicorn | ||
| 54 | + | ||
| 55 | + logger.info(f'docs_url: http://127.0.0.1:{settings.port}/docs') | ||
| 56 | + uvicorn.run( | ||
| 57 | + app, | ||
| 58 | + host='0.0.0.0', | ||
| 59 | + port=settings.port, | ||
| 60 | + reload=settings.debug, | ||
| 61 | + log_config=None | ||
| 62 | + ) | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +if __name__ == "__main__": | ||
| 66 | + main() |
| 1 | -from functools import lru_cache | ||
| 2 | - | ||
| 3 | -from embedding_sever.db import get_data_dao | ||
| 4 | -from .data_service import DataService | ||
| 5 | - | ||
| 6 | - | ||
| 7 | -@lru_cache() | ||
| 8 | -def get_data_service(): | ||
| 9 | - data_dao = get_data_dao() | ||
| 10 | - return DataService(data_dao) | 1 | +from functools import lru_cache |
| 2 | + | ||
| 3 | +from embedding_sever.db import get_data_dao | ||
| 4 | +from .data_service import DataService | ||
| 5 | + | ||
| 6 | + | ||
| 7 | +@lru_cache() | ||
| 8 | +def get_data_service(): | ||
| 9 | + data_dao = get_data_dao() | ||
| 10 | + return DataService(data_dao) |
| 1 | -import logging | ||
| 2 | - | ||
| 3 | -from embedding_sever.db import DataDao | ||
| 4 | - | ||
| 5 | -logger = logging.getLogger(__name__) | ||
| 6 | - | ||
| 7 | - | ||
| 8 | -class DataService: | ||
| 9 | - | ||
| 10 | - def __init__(self, data_dao: DataDao): | ||
| 11 | - self.data_dao = data_dao | ||
| 12 | - | ||
| 13 | - async def upsert(self, tb_name, id, embedding, params): | ||
| 14 | - return await self.data_dao.upsert(tb_name, id, embedding, params) | ||
| 15 | - | ||
| 16 | - async def delete_by_pks(self, tb_name, ids): | ||
| 17 | - return await self.data_dao.delete_by_pks(tb_name, ids) | ||
| 18 | - | ||
| 19 | - async def search(self, tb_name, embedding, filters, from_, size): | ||
| 20 | - return await self.data_dao.search(tb_name, embedding, filters, from_, size) | 1 | +import logging |
| 2 | + | ||
| 3 | +from embedding_sever.db import DataDao | ||
| 4 | + | ||
| 5 | +logger = logging.getLogger(__name__) | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +class DataService: | ||
| 9 | + | ||
| 10 | + def __init__(self, data_dao: DataDao): | ||
| 11 | + self.data_dao = data_dao | ||
| 12 | + | ||
| 13 | + async def upsert(self, tb_name, id, embedding, params): | ||
| 14 | + return await self.data_dao.upsert(tb_name, id, embedding, params) | ||
| 15 | + | ||
| 16 | + async def delete_by_pks(self, tb_name, ids): | ||
| 17 | + return await self.data_dao.delete_by_pks(tb_name, ids) | ||
| 18 | + | ||
| 19 | + async def search(self, tb_name, embedding, filters, from_, size): | ||
| 20 | + return await self.data_dao.search(tb_name, embedding, filters, from_, size) |
| 1 | -kafka_servers: 192.168.0.14:9092 | ||
| 2 | - | ||
| 3 | -llm: | ||
| 4 | - base_url: http://192.168.1.59:11434/v1 | ||
| 5 | - model_name: 'Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf' | ||
| 6 | - | ||
| 7 | -common: | 1 | +kafka_servers: 192.168.0.14:9092 |
| 2 | + | ||
| 3 | +llm: | ||
| 4 | + base_url: http://192.168.1.59:11434/v1 | ||
| 5 | + model_name: 'Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf' | ||
| 6 | + | ||
| 7 | +common: | ||
| 8 | cache_dir: ./cache | 8 | cache_dir: ./cache |
| 1 | -app_name: "Football Replay Service" | ||
| 2 | -app_version: "1.0.0" | ||
| 3 | - | ||
| 4 | -match_by_time_threshold: 30 # 通过时间匹配进球事件的阈值(秒) | ||
| 5 | - | ||
| 6 | -#kafka_servers: 192.168.0.14:9092 | ||
| 7 | -kafka_username: | ||
| 8 | -kafka_password: | ||
| 9 | - | ||
| 10 | -input_kafka: | ||
| 11 | - servers: ${kafka_servers} | ||
| 12 | - group_id: ai_match_service | ||
| 13 | - topic: football_replay_match_req | ||
| 14 | - username: ${kafka_username} | ||
| 15 | - password: ${kafka_password} | ||
| 16 | - | ||
| 17 | -output_kafka: | ||
| 18 | - servers: ${kafka_servers} | ||
| 19 | - topic: football_replay_match_resp | ||
| 20 | - username: ${kafka_username} | ||
| 21 | - password: ${kafka_password} | ||
| 22 | - | ||
| 23 | -llm: | ||
| 24 | - base_url: http://192.168.1.59:11434/v1 | ||
| 25 | - model_name: Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf | ||
| 26 | - temperature: 0.7 | ||
| 27 | - | ||
| 28 | -common: | 1 | +app_name: "Football Replay Service" |
| 2 | +app_version: "1.0.0" | ||
| 3 | + | ||
| 4 | +match_by_time_threshold: 30 # 通过时间匹配进球事件的阈值(秒) | ||
| 5 | + | ||
| 6 | +#kafka_servers: 192.168.0.14:9092 | ||
| 7 | +kafka_username: | ||
| 8 | +kafka_password: | ||
| 9 | + | ||
| 10 | +input_kafka: | ||
| 11 | + servers: ${kafka_servers} | ||
| 12 | + group_id: ai_match_service | ||
| 13 | + topic: football_replay_match_req | ||
| 14 | + username: ${kafka_username} | ||
| 15 | + password: ${kafka_password} | ||
| 16 | + | ||
| 17 | +output_kafka: | ||
| 18 | + servers: ${kafka_servers} | ||
| 19 | + topic: football_replay_match_resp | ||
| 20 | + username: ${kafka_username} | ||
| 21 | + password: ${kafka_password} | ||
| 22 | + | ||
| 23 | +llm: | ||
| 24 | + base_url: http://192.168.1.59:11434/v1 | ||
| 25 | + model_name: Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf | ||
| 26 | + temperature: 0.7 | ||
| 27 | + | ||
| 28 | +common: | ||
| 29 | cache_dir: ./cache | 29 | cache_dir: ./cache |
| 1 | -from aabd.base.cfg_loader import load_yaml_by_file_with_env | ||
| 2 | -from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | ||
| 3 | -from pathlib import Path | ||
| 4 | -from logging import getLogger | ||
| 5 | - | ||
| 6 | -logger = getLogger(__name__) | ||
| 7 | -cfg_dir = Path(__file__).parent.absolute() / 'cfg' | ||
| 8 | -settings = EnhanceDict(load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix())) | ||
| 9 | -settings.update(read_prefixed_env_vars('EMB_')) | ||
| 10 | - | ||
| 11 | -logger.info(f'Settings: {settings}') | 1 | +from aabd.base.cfg_loader import load_yaml_by_file_with_env |
| 2 | +from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | ||
| 3 | +from pathlib import Path | ||
| 4 | +from logging import getLogger | ||
| 5 | + | ||
| 6 | +logger = getLogger(__name__) | ||
| 7 | +cfg_dir = Path(__file__).parent.absolute() / 'cfg' | ||
| 8 | +settings = EnhanceDict(load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix())) | ||
| 9 | +settings.update(read_prefixed_env_vars('EMB_')) | ||
| 10 | + | ||
| 11 | +logger.info(f'Settings: {settings}') |
| 1 | -import os | ||
| 2 | -import logging | ||
| 3 | -from aabd.base.enhance_dict import value_or_default | ||
| 4 | - | ||
| 5 | -logger = logging.getLogger(__name__) | ||
| 6 | - | ||
| 7 | -from utils.football_replay_video_event_by_llm import FootballReplayVideoEvent | ||
| 8 | -from utils.football_replay_match_live import FootballReplayMatchLive | ||
| 9 | - | ||
| 10 | -class FootballReplayMatch: | ||
| 11 | - def __init__(self, settings): | ||
| 12 | - self.settings = settings | ||
| 13 | - self.match_by_time_threshold = value_or_default(settings.match_by_time_threshold, 30) * 1000 | ||
| 14 | - | ||
| 15 | - llm_base_url = value_or_default(settings.llm.base_url, None) | ||
| 16 | - model_name = value_or_default(settings.llm.model_name, None) | ||
| 17 | - temperature = value_or_default(settings.llm.temperature, 0.7) | ||
| 18 | - self.cache_dir = value_or_default(settings.common.cache_dir, None) | ||
| 19 | - save_frames_enable = value_or_default(settings.save_frames_enable, False) | ||
| 20 | - | ||
| 21 | - self.videoEventRecognition = FootballReplayVideoEvent(llm_base_url, model_name, temperature, 'no_key', self.cache_dir, save_frames_enable) | ||
| 22 | - self.videoMatchLive = FootballReplayMatchLive(llm_base_url, model_name, temperature, 'no_key', self.cache_dir, save_frames_enable) | ||
| 23 | - | ||
| 24 | - def match_by_time(self, replay, events): | ||
| 25 | - start_utc = replay.get('start_utc') | ||
| 26 | - | ||
| 27 | - events = [(start_utc - e.get('event_utc'), e) for e in events] | ||
| 28 | - events.sort(key=lambda x: x[0]) | ||
| 29 | - | ||
| 30 | - target_index = next((i for i, e in enumerate(events) if e[0] > 0), -1) # 找到第一个 event_utc 早于 start_utc 的事件 | ||
| 31 | - | ||
| 32 | - if target_index == -1: | ||
| 33 | - return None | ||
| 34 | - | ||
| 35 | - time_diff, event = events[target_index] | ||
| 36 | - if time_diff < self.match_by_time_threshold: | ||
| 37 | - return event | ||
| 38 | - else: | ||
| 39 | - return None | ||
| 40 | - | ||
| 41 | - def det_goal_replay(self, replay, task_id): | ||
| 42 | - return self.videoEventRecognition.video_event(replay, cache_dir=os.path.join(self.cache_dir, task_id, "replay")) | ||
| 43 | - | ||
| 44 | - | ||
| 45 | - def match_by_llm(self, replay, events, task_id): | ||
| 46 | - return self.videoMatchLive.match_batch(replay, events, max_parallel=2, cache_dir=os.path.join(self.cache_dir, task_id, "envents")) | ||
| 47 | - | ||
| 48 | - def replay_match_event(self, data): | ||
| 49 | - """ | ||
| 50 | - | ||
| 51 | - :param data: | ||
| 52 | - { | ||
| 53 | - id:xx, | ||
| 54 | - match_id: 比赛id, | ||
| 55 | - replay:{ | ||
| 56 | - id:, | ||
| 57 | - url:, | ||
| 58 | - start_utc:, | ||
| 59 | - end_utc:, | ||
| 60 | - } | ||
| 61 | - events:[{ | ||
| 62 | - id:xx, | ||
| 63 | - type:xx, | ||
| 64 | - url:xx, | ||
| 65 | - event_utc, | ||
| 66 | - }] | ||
| 67 | - } | ||
| 68 | - :return: | ||
| 69 | - """ | ||
| 70 | - task_id = data.get("id") | ||
| 71 | - match_id = data.get("match_id") | ||
| 72 | - | ||
| 73 | - matched_event_id = None | ||
| 74 | - | ||
| 75 | - # 该时间段附近是否有进球事件 | ||
| 76 | - replay_info = data['replay'] | ||
| 77 | - replay_id = replay_info.get('id') | ||
| 78 | - live_events = data['events'] | ||
| 79 | - goal_live_events = [e for e in live_events if str(e.get('type', '')) == '1'] | ||
| 80 | - | ||
| 81 | - matched_event = self.match_by_time(replay_info, goal_live_events) | ||
| 82 | - if matched_event is None: | ||
| 83 | - replay_det_info = self.det_goal_replay(replay_info, task_id) | ||
| 84 | - replay_event_name = replay_det_info.get('event_name', '') | ||
| 85 | - if replay_event_name == '进球': | ||
| 86 | - matched_event = self.match_by_llm(replay_info, goal_live_events, task_id) | ||
| 87 | - if matched_event is None: | ||
| 88 | - logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件") | ||
| 89 | - # print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件") | ||
| 90 | - else: | ||
| 91 | - matched_event_id = matched_event.get('video_id') | ||
| 92 | - logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 93 | - # print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 94 | - else: | ||
| 95 | - logger.info(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配") | ||
| 96 | - # print(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配") | ||
| 97 | - else: | ||
| 98 | - matched_event_id = matched_event.get('id') | ||
| 99 | - logger.info(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 100 | - # print(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 101 | - return { | ||
| 102 | - "id": task_id, | ||
| 103 | - "match_id": match_id, | ||
| 104 | - "replay_id": replay_id, | ||
| 105 | - "event_id": matched_event_id | 1 | +import os |
| 2 | +import logging | ||
| 3 | +from aabd.base.enhance_dict import value_or_default | ||
| 4 | + | ||
| 5 | +logger = logging.getLogger(__name__) | ||
| 6 | + | ||
| 7 | +from utils.football_replay_video_event_by_llm import FootballReplayVideoEvent | ||
| 8 | +from utils.football_replay_match_live import FootballReplayMatchLive | ||
| 9 | + | ||
| 10 | +class FootballReplayMatch: | ||
| 11 | + def __init__(self, settings): | ||
| 12 | + self.settings = settings | ||
| 13 | + self.match_by_time_threshold = value_or_default(settings.match_by_time_threshold, 30) * 1000 | ||
| 14 | + | ||
| 15 | + llm_base_url = value_or_default(settings.llm.base_url, None) | ||
| 16 | + model_name = value_or_default(settings.llm.model_name, None) | ||
| 17 | + temperature = value_or_default(settings.llm.temperature, 0.7) | ||
| 18 | + self.cache_dir = value_or_default(settings.common.cache_dir, None) | ||
| 19 | + save_frames_enable = value_or_default(settings.save_frames_enable, False) | ||
| 20 | + | ||
| 21 | + self.videoEventRecognition = FootballReplayVideoEvent(llm_base_url, model_name, temperature, 'no_key', self.cache_dir, save_frames_enable) | ||
| 22 | + self.videoMatchLive = FootballReplayMatchLive(llm_base_url, model_name, temperature, 'no_key', self.cache_dir, save_frames_enable) | ||
| 23 | + | ||
| 24 | + def match_by_time(self, replay, events): | ||
| 25 | + start_utc = replay.get('start_utc') | ||
| 26 | + | ||
| 27 | + events = [(start_utc - e.get('event_utc'), e) for e in events] | ||
| 28 | + events.sort(key=lambda x: x[0]) | ||
| 29 | + | ||
| 30 | + target_index = next((i for i, e in enumerate(events) if e[0] > 0), -1) # 找到第一个 event_utc 早于 start_utc 的事件 | ||
| 31 | + | ||
| 32 | + if target_index == -1: | ||
| 33 | + return None | ||
| 34 | + | ||
| 35 | + time_diff, event = events[target_index] | ||
| 36 | + if time_diff < self.match_by_time_threshold: | ||
| 37 | + return event | ||
| 38 | + else: | ||
| 39 | + return None | ||
| 40 | + | ||
| 41 | + def det_goal_replay(self, replay, task_id): | ||
| 42 | + return self.videoEventRecognition.video_event(replay, cache_dir=os.path.join(self.cache_dir, task_id, "replay")) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | + def match_by_llm(self, replay, events, task_id): | ||
| 46 | + return self.videoMatchLive.match_batch(replay, events, max_parallel=2, cache_dir=os.path.join(self.cache_dir, task_id, "envents")) | ||
| 47 | + | ||
| 48 | + def replay_match_event(self, data): | ||
| 49 | + """ | ||
| 50 | + | ||
| 51 | + :param data: | ||
| 52 | + { | ||
| 53 | + id:xx, | ||
| 54 | + match_id: 比赛id, | ||
| 55 | + replay:{ | ||
| 56 | + id:, | ||
| 57 | + url:, | ||
| 58 | + start_utc:, | ||
| 59 | + end_utc:, | ||
| 60 | + } | ||
| 61 | + events:[{ | ||
| 62 | + id:xx, | ||
| 63 | + type:xx, | ||
| 64 | + url:xx, | ||
| 65 | + event_utc, | ||
| 66 | + }] | ||
| 67 | + } | ||
| 68 | + :return: | ||
| 69 | + """ | ||
| 70 | + task_id = data.get("id") | ||
| 71 | + match_id = data.get("match_id") | ||
| 72 | + | ||
| 73 | + matched_event_id = None | ||
| 74 | + | ||
| 75 | + # 该时间段附近是否有进球事件 | ||
| 76 | + replay_info = data['replay'] | ||
| 77 | + replay_id = replay_info.get('id') | ||
| 78 | + live_events = data['events'] | ||
| 79 | + goal_live_events = [e for e in live_events if str(e.get('type', '')) == '1'] | ||
| 80 | + | ||
| 81 | + matched_event = self.match_by_time(replay_info, goal_live_events) | ||
| 82 | + if matched_event is None: | ||
| 83 | + replay_det_info = self.det_goal_replay(replay_info, task_id) | ||
| 84 | + replay_event_name = replay_det_info.get('event_name', '') | ||
| 85 | + if replay_event_name == '进球': | ||
| 86 | + matched_event = self.match_by_llm(replay_info, goal_live_events, task_id) | ||
| 87 | + if matched_event is None: | ||
| 88 | + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件") | ||
| 89 | + # print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件") | ||
| 90 | + else: | ||
| 91 | + matched_event_id = matched_event.get('video_id') | ||
| 92 | + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 93 | + # print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 94 | + else: | ||
| 95 | + logger.info(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配") | ||
| 96 | + # print(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配") | ||
| 97 | + else: | ||
| 98 | + matched_event_id = matched_event.get('id') | ||
| 99 | + logger.info(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 100 | + # print(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 101 | + return { | ||
| 102 | + "id": task_id, | ||
| 103 | + "match_id": match_id, | ||
| 104 | + "replay_id": replay_id, | ||
| 105 | + "event_id": matched_event_id | ||
| 106 | } | 106 | } |
| 1 | -import json | ||
| 2 | -import os.path | ||
| 3 | -from pathlib import Path | ||
| 4 | -from datetime import timedelta | ||
| 5 | - | ||
| 6 | -from langchain_core.messages import SystemMessage, HumanMessage | ||
| 7 | -from langchain_openai import ChatOpenAI | ||
| 8 | - | ||
| 9 | -try: | ||
| 10 | - from .llm_image import Video2Frame | ||
| 11 | -except: | ||
| 12 | - from llm_image import Video2Frame | ||
| 13 | - | ||
| 14 | -req_prompt = """ | ||
| 15 | -# Role | ||
| 16 | -你是一名拥有20年经验的足球视频技术分析师,擅长结合**视觉画面**与**解说音频(ASR)**进行跨镜头的事件匹配。你能通过解说员的描述锁定时间背景,并通过视觉特征确认物理细节。 | ||
| 17 | - | ||
| 18 | -# Task | ||
| 19 | -输入包含: | ||
| 20 | -1. 【回放片段】(Replay):包含视频帧 + **对应的解说文本**。 | ||
| 21 | -2. 【直播进球片段列表】(Live Candidates):包含视频帧 + **对应的解说文本**。 | ||
| 22 | - | ||
| 23 | -目标:在【直播片段】中找到与【回放片段】属于**同一个进球事件**的片段。如果没有任何片段匹配,返回 null。 | ||
| 24 | - | ||
| 25 | -# Analysis Workflow (多模态分析流程) | ||
| 26 | - | ||
| 27 | -请按照以下步骤进行推理: | ||
| 28 | - | ||
| 29 | -### 第一步:解说词元数据提取 (听觉线索) | ||
| 30 | -分析【回放片段】的解说文本,寻找以下关键信息: | ||
| 31 | -- **时间指代**:解说员是否提到了具体时间?(如“上半场”、“第10分钟”、“开场不久”)。 | ||
| 32 | -- **事件描述**:解说员如何描述这个进球?(如“世界波”、“点球”、“补射”、“乌龙球”)。 | ||
| 33 | -- **球员提及**:解说员念出了谁的名字? | ||
| 34 | - | ||
| 35 | -### 第二步:视觉物理指纹提取 (视觉线索) | ||
| 36 | -忽略镜头语言(慢放、特写),提取核心物理特征: | ||
| 37 | -- **进攻与射门**:进攻方式(传中/直塞)、射门部位(头/脚)、射门位置。 | ||
| 38 | -- **球路与防守**:球的轨迹(高/低/折射)、门将扑救动作(侧扑/倒地/未动)。 | ||
| 39 | -- **庆祝**:进球后的庆祝动作(仅作为辅助验证)。 | ||
| 40 | - | ||
| 41 | -### 第三步:跨模态匹配与验证 | ||
| 42 | -遍历【直播片段】,结合视觉和听觉进行判断: | ||
| 43 | -- **视觉一致性**:直播片段的动作、轨迹、门将反应是否与回放完全吻合? | ||
| 44 | -- **听觉一致性**: | ||
| 45 | - - 如果回放解说提到“这是第5分钟的进球”,而直播片段的时间戳是90分钟,**不要直接排除**,而是检查直播片段的解说是否也提到了“回顾第5分钟”或者直播片段的视觉内容确实是第5分钟的动作。 | ||
| 46 | - - **核心原则**:视觉物理特征 > 解说词描述 > 时间戳元数据。 | ||
| 47 | - - **特殊情况**:如果视觉特征高度相似(如同一个球员、同一个角度),但解说词明确说“这是另一个进球(例如:这是他的第二个进球,而回放说是第一个)”,则判定为不匹配。 | ||
| 48 | - | ||
| 49 | -# Logic Constraints (逻辑约束 - 必须严格遵守) | ||
| 50 | - | ||
| 51 | -- **时间单向性原则(铁律)**: | ||
| 52 | - - **回看中的比赛时间一定在比赛片段画面时间之后**。 | ||
| 53 | - - 逻辑:回放是对过去发生事件的回顾。如果【回放片段】中解说提到的比赛时间(或画面显示的比赛时钟)**早于**【直播片段】中解说提到的比赛时间(或画面时钟),则**绝对不可能**是同一个事件。 | ||
| 54 | - - *示例*:回放解说在描述“第10分钟的进球”,而直播片段明确发生在“第5分钟”,则该直播片段**一定不是**目标。 | ||
| 55 | -- **时间陷阱**:回放可能是赛后集锦。如果解说员说“让我们看看**刚才**那个球”或者“**上半场**那个球”,即使当前比赛时间是90分钟,也要去匹配对应时间段的直播片段(或视觉特征)。 | ||
| 56 | -- **同名陷阱**:如果解说提到“又是**凯恩**进球了”,不能只看凯恩,必须看**怎么进的**(头球还是点球)。 | ||
| 57 | -- **无匹配处理**:如果所有候选片段在视觉动作(如射门方式、进球位置)或关键事件逻辑上与回放明显不符,必须判定为无匹配,将 `video_id` 设为 `null`。 | ||
| 58 | - | ||
| 59 | -### 输出要求 | ||
| 60 | - | ||
| 61 | -请仅输出一个JSON格式的结果,不要输出任何分析过程。不要包含 markdown 标记(如 ```json ... ```),不要包含任何解释或额外文本。 | ||
| 62 | -格式如下: | ||
| 63 | -{ | ||
| 64 | - "replay_summary": { | ||
| 65 | - "audio_cues": "解说提到的关键信息(如:'第15分钟', '远射', '德布劳内')", | ||
| 66 | - "visual_cues": "视觉关键特征(如:'禁区外右脚', '球挂死角', '门将飞身扑救')" | ||
| 67 | - }, | ||
| 68 | - "reasoning": "综合分析:回放解说提到是'上半场的远射',视觉显示'17号球员禁区外起脚'。Candidate_1 视觉是'近距离推射',排除。Candidate_2 视觉是'禁区外远射',且门将动作一致,虽然直播时间显示是下半场(可能是集锦回顾),但解说也提到了'回顾上半场',确认为同一事件。", | ||
| 69 | - "video_id": "Candidate_2" | ||
| 70 | -}""" | ||
| 71 | - | ||
| 72 | - | ||
| 73 | -class FootballReplayMatchLive: | ||
| 74 | - def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key', cache_dir:str=None, save_frames_enable:bool=False): | ||
| 75 | - self.base_url = base_url | ||
| 76 | - self.model = model | ||
| 77 | - self.temperature = temperature | ||
| 78 | - self.api_key = api_key | ||
| 79 | - # self.model = ChatOllama(base_url="http://192.168.1.59:11434", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 80 | - # keep_alive=-1, reasoning=False) | ||
| 81 | - # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 82 | - # api_key='no_key') | ||
| 83 | - self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | ||
| 84 | - extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 85 | - self.cache_dir = cache_dir | ||
| 86 | - self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | ||
| 87 | - | ||
| 88 | - def _match_once(self, replay_video_contents: list, live_videos: list[dict], record: list = None): | ||
| 89 | - | ||
| 90 | - if len(live_videos) == 0: | ||
| 91 | - return None | ||
| 92 | - elif len(live_videos) == 1: | ||
| 93 | - live_video_path = live_videos[0].get("url", None) | ||
| 94 | - live_video_id = live_videos[0].get("video_id", os.path.basename(live_video_path)) | ||
| 95 | - asr_text = live_videos[0].get("asr_text", '') | ||
| 96 | - live = { | ||
| 97 | - "video_id": live_video_id, | ||
| 98 | - "video_path": live_video_path, | ||
| 99 | - "asr_text": asr_text, | ||
| 100 | - } | ||
| 101 | - if record is not None: | ||
| 102 | - record.append({"live": live, "llm_result": None, 'live_list': [live]}) | ||
| 103 | - return live | ||
| 104 | - user_contents = [] | ||
| 105 | - # replay_video_contents = video_contents(replay_video["video_path"], "\n【回放片段信息】\n", | ||
| 106 | - # prompt_end=f"\n回放解说内容:{replay_video['asr_text']}\n", | ||
| 107 | - # video_name=os.path.basename(replay_video["video_path"]), | ||
| 108 | - # fps=2, max_frames=999, sampling_mode="head", max_short_edge=480) | ||
| 109 | - live_videos_contents = [] | ||
| 110 | - live_map = {} | ||
| 111 | - live_records = {} | ||
| 112 | - for live_video in live_videos: | ||
| 113 | - live_video_path = live_video.get("url", None) | ||
| 114 | - live_video_id = live_video.get("video_id", os.path.basename(live_video_path)) | ||
| 115 | - event_utc = live_video.get("event_utc", None) | ||
| 116 | - asr_text = live_video.get("asr_text", '') | ||
| 117 | - live_video_contents = live_video.get("llm_contents", None) | ||
| 118 | - if live_video_contents is None: | ||
| 119 | - # event_start = event_utc - timedelta(seconds=10) if event_utc is not None else None | ||
| 120 | - event_start = event_utc - 10000 if event_utc is not None else None | ||
| 121 | - # event_end = event_utc + timedelta(seconds=5) if event_utc is not None else None | ||
| 122 | - event_end = event_utc + 5000 if event_utc is not None else None | ||
| 123 | - live_video_contents = self.video2frame.to_llm_contents( live_video_path, | ||
| 124 | - cache=self.cache_dir, | ||
| 125 | - fps=2, | ||
| 126 | - start=event_start, | ||
| 127 | - end=event_end, | ||
| 128 | - roi=None, | ||
| 129 | - max_px_area=400_000, | ||
| 130 | - prompt_start=f"### 候选片段 video_id: {live_video_id} ###", | ||
| 131 | - prompt_end=f"\n该片段解说内容: {asr_text}\n" | ||
| 132 | - ) | ||
| 133 | - live_map[live_video_id] = { | ||
| 134 | - "video_id": live_video_id, | ||
| 135 | - "video_path": live_video_path, | ||
| 136 | - "event_utc": event_utc, | ||
| 137 | - "asr_text": asr_text, | ||
| 138 | - "contents": live_video_contents, | ||
| 139 | - | ||
| 140 | - } | ||
| 141 | - live_records[live_video_id] = { | ||
| 142 | - "video_id": live_video_id, | ||
| 143 | - "video_path": live_video_path, | ||
| 144 | - "event_utc": event_utc, | ||
| 145 | - "asr_text": asr_text, | ||
| 146 | - } | ||
| 147 | - live_videos_contents.extend(live_video_contents) | ||
| 148 | - | ||
| 149 | - user_contents.extend(replay_video_contents) | ||
| 150 | - user_contents.append({'type': "text", "text": "\n【候选直播片段列表】\n"}) | ||
| 151 | - user_contents.extend(live_videos_contents) | ||
| 152 | - user_contents.append({'type': "text", "text": "\n请根据上述片段进行匹配并按要求输出结果\n"}) | ||
| 153 | - system_message = SystemMessage(content=req_prompt) | ||
| 154 | - user_message = HumanMessage(content=user_contents) | ||
| 155 | - result = self.model.invoke([system_message, user_message]).content | ||
| 156 | - try: | ||
| 157 | - result_json = json.loads(result) | ||
| 158 | - except json.JSONDecodeError: | ||
| 159 | - try: | ||
| 160 | - result_json = json.loads(result.replace("```json", "").replace("```", "")) | ||
| 161 | - except Exception as e: | ||
| 162 | - print("JSON解析失败:", result) | ||
| 163 | - raise e | ||
| 164 | - | ||
| 165 | - video_id = result_json.get("video_id", None) | ||
| 166 | - result_live = live_map.get(video_id, None) | ||
| 167 | - if record is not None: | ||
| 168 | - record.append( | ||
| 169 | - {"live": live_records.get(video_id,None), "llm_result": result_json, 'live_list': list(live_records.values())}) | ||
| 170 | - return result_live | ||
| 171 | - | ||
| 172 | - def _match_batch(self, replay_video_contents: list, live_videos: list[dict], max_parallel: int = 3, cache_path=None, | ||
| 173 | - record: list = None): | ||
| 174 | - """ | ||
| 175 | - Match a replay video with live videos to find the most likely match. | ||
| 176 | - :param max_parallel: | ||
| 177 | - :param replay_video: Path to the replay video.(video_path,asr_text) | ||
| 178 | - :param live_videos: [(video_id,video_path,asr_text)] | ||
| 179 | - :param cache_path: Path to cache the result. | ||
| 180 | - :return: JSON object containing the match result. | ||
| 181 | - """ | ||
| 182 | - if cache_path is not None and os.path.exists(cache_path): | ||
| 183 | - with open(cache_path, 'r', encoding='utf-8') as f: | ||
| 184 | - return json.loads(f.read()) | ||
| 185 | - | ||
| 186 | - # 按照max_parallel对live_videos进行分组 | ||
| 187 | - live_videos_groups = [live_videos[i::max_parallel] for i in range(max_parallel)] | ||
| 188 | - # 过滤掉空的分组 | ||
| 189 | - live_videos_groups = [g for g in live_videos_groups if g] | ||
| 190 | - # 如果group 大于1 并且最后一个group 只有一个元素,将其唯一元素放入倒数第二个group,并删除最后一个group | ||
| 191 | - if len(live_videos_groups) > 1 and len(live_videos_groups[-1]) == 1: | ||
| 192 | - live_videos_groups[-2].append(live_videos_groups[-1][0]) | ||
| 193 | - live_videos_groups.pop() | ||
| 194 | - | ||
| 195 | - if len(live_videos_groups) > 1: | ||
| 196 | - match_result = [] | ||
| 197 | - for live_videos_group in live_videos_groups: | ||
| 198 | - g_live = self._match_once(replay_video_contents, live_videos_group, record) | ||
| 199 | - if g_live is None: | ||
| 200 | - continue | ||
| 201 | - match_result.append(g_live) | ||
| 202 | - if len(match_result) == 0: | ||
| 203 | - return None | ||
| 204 | - else: | ||
| 205 | - return self._match_batch(replay_video_contents, match_result, max_parallel, cache_path, record) | ||
| 206 | - elif len(live_videos_groups) == 1: | ||
| 207 | - return self._match_once(replay_video_contents, live_videos_groups[0], record) | ||
| 208 | - else: | ||
| 209 | - return None | ||
| 210 | - | ||
| 211 | - def match_batch(self, replay_video: dict, live_videos: list[dict], max_parallel: int = 3, cache_dir=None): | ||
| 212 | - | ||
| 213 | - self.cache_dir = cache_dir | ||
| 214 | - cache_path = os.path.join(cache_dir, 'match_live.json') | ||
| 215 | - if cache_path is not None and os.path.exists(cache_path): | ||
| 216 | - try: | ||
| 217 | - with open(cache_path, 'r', encoding='utf-8') as f: | ||
| 218 | - return json.loads(f.read()).get("result", None) | ||
| 219 | - except: | ||
| 220 | - os.remove(cache_path) | ||
| 221 | - | ||
| 222 | - replay_video_path = replay_video.get("url", None) | ||
| 223 | - event_start = replay_video.get("start_utc", None) | ||
| 224 | - event_end = replay_video.get("end_utc", None) | ||
| 225 | - replay_video_contents = self.video2frame.to_llm_contents(replay_video_path, | ||
| 226 | - cache=os.path.join(os.path.dirname(cache_dir), "replay"), | ||
| 227 | - fps=2, | ||
| 228 | - start=event_start, | ||
| 229 | - end=event_end, | ||
| 230 | - roi=None, | ||
| 231 | - max_px_area=400_000, | ||
| 232 | - prompt_start="\n【回放片段信息】\n", | ||
| 233 | - prompt_end=f"\n回放解说内容:无\n" | ||
| 234 | - ) | ||
| 235 | - live_record = [] | ||
| 236 | - result = self._match_batch(replay_video_contents, live_videos, max_parallel, cache_path, live_record) | ||
| 237 | - if result is not None: | ||
| 238 | - result_no_content = { | ||
| 239 | - "video_id": result.get("video_id", None), | ||
| 240 | - "video_path": result.get("video_path", None), | ||
| 241 | - "event_utc": result.get("event_utc", None), | ||
| 242 | - "asr_text": result.get("asr_text", None), | ||
| 243 | - } | ||
| 244 | - else: | ||
| 245 | - result_no_content = None | ||
| 246 | - record = { | ||
| 247 | - "request": { | ||
| 248 | - "replay_video": replay_video, | ||
| 249 | - "live_videos": live_videos, | ||
| 250 | - "max_parallel": max_parallel, | ||
| 251 | - "cache_path": cache_path | ||
| 252 | - }, | ||
| 253 | - "result": result_no_content, | ||
| 254 | - "live_record": live_record | ||
| 255 | - } | ||
| 256 | - if cache_path is not None: | ||
| 257 | - os.makedirs(Path(cache_path).parent, exist_ok=True) | ||
| 258 | - with open(cache_path, 'w', encoding='utf-8') as f: | ||
| 259 | - f.write(json.dumps(record, ensure_ascii=False, indent=4)) | 1 | +import json |
| 2 | +import os.path | ||
| 3 | +from pathlib import Path | ||
| 4 | +from datetime import timedelta | ||
| 5 | + | ||
| 6 | +from langchain_core.messages import SystemMessage, HumanMessage | ||
| 7 | +from langchain_openai import ChatOpenAI | ||
| 8 | + | ||
| 9 | +try: | ||
| 10 | + from .llm_image import Video2Frame | ||
| 11 | +except: | ||
| 12 | + from llm_image import Video2Frame | ||
| 13 | + | ||
| 14 | +req_prompt = """ | ||
| 15 | +# Role | ||
| 16 | +你是一名拥有20年经验的足球视频技术分析师,擅长结合**视觉画面**与**解说音频(ASR)**进行跨镜头的事件匹配。你能通过解说员的描述锁定时间背景,并通过视觉特征确认物理细节。 | ||
| 17 | + | ||
| 18 | +# Task | ||
| 19 | +输入包含: | ||
| 20 | +1. 【回放片段】(Replay):包含视频帧 + **对应的解说文本**。 | ||
| 21 | +2. 【直播进球片段列表】(Live Candidates):包含视频帧 + **对应的解说文本**。 | ||
| 22 | + | ||
| 23 | +目标:在【直播片段】中找到与【回放片段】属于**同一个进球事件**的片段。如果没有任何片段匹配,返回 null。 | ||
| 24 | + | ||
| 25 | +# Analysis Workflow (多模态分析流程) | ||
| 26 | + | ||
| 27 | +请按照以下步骤进行推理: | ||
| 28 | + | ||
| 29 | +### 第一步:解说词元数据提取 (听觉线索) | ||
| 30 | +分析【回放片段】的解说文本,寻找以下关键信息: | ||
| 31 | +- **时间指代**:解说员是否提到了具体时间?(如“上半场”、“第10分钟”、“开场不久”)。 | ||
| 32 | +- **事件描述**:解说员如何描述这个进球?(如“世界波”、“点球”、“补射”、“乌龙球”)。 | ||
| 33 | +- **球员提及**:解说员念出了谁的名字? | ||
| 34 | + | ||
| 35 | +### 第二步:视觉物理指纹提取 (视觉线索) | ||
| 36 | +忽略镜头语言(慢放、特写),提取核心物理特征: | ||
| 37 | +- **进攻与射门**:进攻方式(传中/直塞)、射门部位(头/脚)、射门位置。 | ||
| 38 | +- **球路与防守**:球的轨迹(高/低/折射)、门将扑救动作(侧扑/倒地/未动)。 | ||
| 39 | +- **庆祝**:进球后的庆祝动作(仅作为辅助验证)。 | ||
| 40 | + | ||
| 41 | +### 第三步:跨模态匹配与验证 | ||
| 42 | +遍历【直播片段】,结合视觉和听觉进行判断: | ||
| 43 | +- **视觉一致性**:直播片段的动作、轨迹、门将反应是否与回放完全吻合? | ||
| 44 | +- **听觉一致性**: | ||
| 45 | + - 如果回放解说提到“这是第5分钟的进球”,而直播片段的时间戳是90分钟,**不要直接排除**,而是检查直播片段的解说是否也提到了“回顾第5分钟”或者直播片段的视觉内容确实是第5分钟的动作。 | ||
| 46 | + - **核心原则**:视觉物理特征 > 解说词描述 > 时间戳元数据。 | ||
| 47 | + - **特殊情况**:如果视觉特征高度相似(如同一个球员、同一个角度),但解说词明确说“这是另一个进球(例如:这是他的第二个进球,而回放说是第一个)”,则判定为不匹配。 | ||
| 48 | + | ||
| 49 | +# Logic Constraints (逻辑约束 - 必须严格遵守) | ||
| 50 | + | ||
| 51 | +- **时间单向性原则(铁律)**: | ||
| 52 | + - **回看中的比赛时间一定在比赛片段画面时间之后**。 | ||
| 53 | + - 逻辑:回放是对过去发生事件的回顾。如果【回放片段】中解说提到的比赛时间(或画面显示的比赛时钟)**早于**【直播片段】中解说提到的比赛时间(或画面时钟),则**绝对不可能**是同一个事件。 | ||
| 54 | + - *示例*:回放解说在描述“第10分钟的进球”,而直播片段明确发生在“第5分钟”,则该直播片段**一定不是**目标。 | ||
| 55 | +- **时间陷阱**:回放可能是赛后集锦。如果解说员说“让我们看看**刚才**那个球”或者“**上半场**那个球”,即使当前比赛时间是90分钟,也要去匹配对应时间段的直播片段(或视觉特征)。 | ||
| 56 | +- **同名陷阱**:如果解说提到“又是**凯恩**进球了”,不能只看凯恩,必须看**怎么进的**(头球还是点球)。 | ||
| 57 | +- **无匹配处理**:如果所有候选片段在视觉动作(如射门方式、进球位置)或关键事件逻辑上与回放明显不符,必须判定为无匹配,将 `video_id` 设为 `null`。 | ||
| 58 | + | ||
| 59 | +### 输出要求 | ||
| 60 | + | ||
| 61 | +请仅输出一个JSON格式的结果,不要输出任何分析过程。不要包含 markdown 标记(如 ```json ... ```),不要包含任何解释或额外文本。 | ||
| 62 | +格式如下: | ||
| 63 | +{ | ||
| 64 | + "replay_summary": { | ||
| 65 | + "audio_cues": "解说提到的关键信息(如:'第15分钟', '远射', '德布劳内')", | ||
| 66 | + "visual_cues": "视觉关键特征(如:'禁区外右脚', '球挂死角', '门将飞身扑救')" | ||
| 67 | + }, | ||
| 68 | + "reasoning": "综合分析:回放解说提到是'上半场的远射',视觉显示'17号球员禁区外起脚'。Candidate_1 视觉是'近距离推射',排除。Candidate_2 视觉是'禁区外远射',且门将动作一致,虽然直播时间显示是下半场(可能是集锦回顾),但解说也提到了'回顾上半场',确认为同一事件。", | ||
| 69 | + "video_id": "Candidate_2" | ||
| 70 | +}""" | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +class FootballReplayMatchLive: | ||
| 74 | + def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key', cache_dir:str=None, save_frames_enable:bool=False): | ||
| 75 | + self.base_url = base_url | ||
| 76 | + self.model = model | ||
| 77 | + self.temperature = temperature | ||
| 78 | + self.api_key = api_key | ||
| 79 | + # self.model = ChatOllama(base_url="http://192.168.1.59:11434", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 80 | + # keep_alive=-1, reasoning=False) | ||
| 81 | + # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 82 | + # api_key='no_key') | ||
| 83 | + self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | ||
| 84 | + extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 85 | + self.cache_dir = cache_dir | ||
| 86 | + self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | ||
| 87 | + | ||
| 88 | + def _match_once(self, replay_video_contents: list, live_videos: list[dict], record: list = None): | ||
| 89 | + | ||
| 90 | + if len(live_videos) == 0: | ||
| 91 | + return None | ||
| 92 | + elif len(live_videos) == 1: | ||
| 93 | + live_video_path = live_videos[0].get("url", None) | ||
| 94 | + live_video_id = live_videos[0].get("video_id", os.path.basename(live_video_path)) | ||
| 95 | + asr_text = live_videos[0].get("asr_text", '') | ||
| 96 | + live = { | ||
| 97 | + "video_id": live_video_id, | ||
| 98 | + "video_path": live_video_path, | ||
| 99 | + "asr_text": asr_text, | ||
| 100 | + } | ||
| 101 | + if record is not None: | ||
| 102 | + record.append({"live": live, "llm_result": None, 'live_list': [live]}) | ||
| 103 | + return live | ||
| 104 | + user_contents = [] | ||
| 105 | + # replay_video_contents = video_contents(replay_video["video_path"], "\n【回放片段信息】\n", | ||
| 106 | + # prompt_end=f"\n回放解说内容:{replay_video['asr_text']}\n", | ||
| 107 | + # video_name=os.path.basename(replay_video["video_path"]), | ||
| 108 | + # fps=2, max_frames=999, sampling_mode="head", max_short_edge=480) | ||
| 109 | + live_videos_contents = [] | ||
| 110 | + live_map = {} | ||
| 111 | + live_records = {} | ||
| 112 | + for live_video in live_videos: | ||
| 113 | + live_video_path = live_video.get("url", None) | ||
| 114 | + live_video_id = live_video.get("video_id", os.path.basename(live_video_path)) | ||
| 115 | + event_utc = live_video.get("event_utc", None) | ||
| 116 | + asr_text = live_video.get("asr_text", '') | ||
| 117 | + live_video_contents = live_video.get("llm_contents", None) | ||
| 118 | + if live_video_contents is None: | ||
| 119 | + # event_start = event_utc - timedelta(seconds=10) if event_utc is not None else None | ||
| 120 | + event_start = event_utc - 10000 if event_utc is not None else None | ||
| 121 | + # event_end = event_utc + timedelta(seconds=5) if event_utc is not None else None | ||
| 122 | + event_end = event_utc + 5000 if event_utc is not None else None | ||
| 123 | + live_video_contents = self.video2frame.to_llm_contents( live_video_path, | ||
| 124 | + cache=self.cache_dir, | ||
| 125 | + fps=2, | ||
| 126 | + start=event_start, | ||
| 127 | + end=event_end, | ||
| 128 | + roi=None, | ||
| 129 | + max_px_area=400_000, | ||
| 130 | + prompt_start=f"### 候选片段 video_id: {live_video_id} ###", | ||
| 131 | + prompt_end=f"\n该片段解说内容: {asr_text}\n" | ||
| 132 | + ) | ||
| 133 | + live_map[live_video_id] = { | ||
| 134 | + "video_id": live_video_id, | ||
| 135 | + "video_path": live_video_path, | ||
| 136 | + "event_utc": event_utc, | ||
| 137 | + "asr_text": asr_text, | ||
| 138 | + "contents": live_video_contents, | ||
| 139 | + | ||
| 140 | + } | ||
| 141 | + live_records[live_video_id] = { | ||
| 142 | + "video_id": live_video_id, | ||
| 143 | + "video_path": live_video_path, | ||
| 144 | + "event_utc": event_utc, | ||
| 145 | + "asr_text": asr_text, | ||
| 146 | + } | ||
| 147 | + live_videos_contents.extend(live_video_contents) | ||
| 148 | + | ||
| 149 | + user_contents.extend(replay_video_contents) | ||
| 150 | + user_contents.append({'type': "text", "text": "\n【候选直播片段列表】\n"}) | ||
| 151 | + user_contents.extend(live_videos_contents) | ||
| 152 | + user_contents.append({'type': "text", "text": "\n请根据上述片段进行匹配并按要求输出结果\n"}) | ||
| 153 | + system_message = SystemMessage(content=req_prompt) | ||
| 154 | + user_message = HumanMessage(content=user_contents) | ||
| 155 | + result = self.model.invoke([system_message, user_message]).content | ||
| 156 | + try: | ||
| 157 | + result_json = json.loads(result) | ||
| 158 | + except json.JSONDecodeError: | ||
| 159 | + try: | ||
| 160 | + result_json = json.loads(result.replace("```json", "").replace("```", "")) | ||
| 161 | + except Exception as e: | ||
| 162 | + print("JSON解析失败:", result) | ||
| 163 | + raise e | ||
| 164 | + | ||
| 165 | + video_id = result_json.get("video_id", None) | ||
| 166 | + result_live = live_map.get(video_id, None) | ||
| 167 | + if record is not None: | ||
| 168 | + record.append( | ||
| 169 | + {"live": live_records.get(video_id,None), "llm_result": result_json, 'live_list': list(live_records.values())}) | ||
| 170 | + return result_live | ||
| 171 | + | ||
| 172 | + def _match_batch(self, replay_video_contents: list, live_videos: list[dict], max_parallel: int = 3, cache_path=None, | ||
| 173 | + record: list = None): | ||
| 174 | + """ | ||
| 175 | + Match a replay video with live videos to find the most likely match. | ||
| 176 | + :param max_parallel: | ||
| 177 | + :param replay_video: Path to the replay video.(video_path,asr_text) | ||
| 178 | + :param live_videos: [(video_id,video_path,asr_text)] | ||
| 179 | + :param cache_path: Path to cache the result. | ||
| 180 | + :return: JSON object containing the match result. | ||
| 181 | + """ | ||
| 182 | + if cache_path is not None and os.path.exists(cache_path): | ||
| 183 | + with open(cache_path, 'r', encoding='utf-8') as f: | ||
| 184 | + return json.loads(f.read()) | ||
| 185 | + | ||
| 186 | + # 按照max_parallel对live_videos进行分组 | ||
| 187 | + live_videos_groups = [live_videos[i::max_parallel] for i in range(max_parallel)] | ||
| 188 | + # 过滤掉空的分组 | ||
| 189 | + live_videos_groups = [g for g in live_videos_groups if g] | ||
| 190 | + # 如果group 大于1 并且最后一个group 只有一个元素,将其唯一元素放入倒数第二个group,并删除最后一个group | ||
| 191 | + if len(live_videos_groups) > 1 and len(live_videos_groups[-1]) == 1: | ||
| 192 | + live_videos_groups[-2].append(live_videos_groups[-1][0]) | ||
| 193 | + live_videos_groups.pop() | ||
| 194 | + | ||
| 195 | + if len(live_videos_groups) > 1: | ||
| 196 | + match_result = [] | ||
| 197 | + for live_videos_group in live_videos_groups: | ||
| 198 | + g_live = self._match_once(replay_video_contents, live_videos_group, record) | ||
| 199 | + if g_live is None: | ||
| 200 | + continue | ||
| 201 | + match_result.append(g_live) | ||
| 202 | + if len(match_result) == 0: | ||
| 203 | + return None | ||
| 204 | + else: | ||
| 205 | + return self._match_batch(replay_video_contents, match_result, max_parallel, cache_path, record) | ||
| 206 | + elif len(live_videos_groups) == 1: | ||
| 207 | + return self._match_once(replay_video_contents, live_videos_groups[0], record) | ||
| 208 | + else: | ||
| 209 | + return None | ||
| 210 | + | ||
| 211 | + def match_batch(self, replay_video: dict, live_videos: list[dict], max_parallel: int = 3, cache_dir=None): | ||
| 212 | + | ||
| 213 | + self.cache_dir = cache_dir | ||
| 214 | + cache_path = os.path.join(cache_dir, 'match_live.json') | ||
| 215 | + if cache_path is not None and os.path.exists(cache_path): | ||
| 216 | + try: | ||
| 217 | + with open(cache_path, 'r', encoding='utf-8') as f: | ||
| 218 | + return json.loads(f.read()).get("result", None) | ||
| 219 | + except: | ||
| 220 | + os.remove(cache_path) | ||
| 221 | + | ||
| 222 | + replay_video_path = replay_video.get("url", None) | ||
| 223 | + event_start = replay_video.get("start_utc", None) | ||
| 224 | + event_end = replay_video.get("end_utc", None) | ||
| 225 | + replay_video_contents = self.video2frame.to_llm_contents(replay_video_path, | ||
| 226 | + cache=os.path.join(os.path.dirname(cache_dir), "replay"), | ||
| 227 | + fps=2, | ||
| 228 | + start=event_start, | ||
| 229 | + end=event_end, | ||
| 230 | + roi=None, | ||
| 231 | + max_px_area=400_000, | ||
| 232 | + prompt_start="\n【回放片段信息】\n", | ||
| 233 | + prompt_end=f"\n回放解说内容:无\n" | ||
| 234 | + ) | ||
| 235 | + live_record = [] | ||
| 236 | + result = self._match_batch(replay_video_contents, live_videos, max_parallel, cache_path, live_record) | ||
| 237 | + if result is not None: | ||
| 238 | + result_no_content = { | ||
| 239 | + "video_id": result.get("video_id", None), | ||
| 240 | + "video_path": result.get("video_path", None), | ||
| 241 | + "event_utc": result.get("event_utc", None), | ||
| 242 | + "asr_text": result.get("asr_text", None), | ||
| 243 | + } | ||
| 244 | + else: | ||
| 245 | + result_no_content = None | ||
| 246 | + record = { | ||
| 247 | + "request": { | ||
| 248 | + "replay_video": replay_video, | ||
| 249 | + "live_videos": live_videos, | ||
| 250 | + "max_parallel": max_parallel, | ||
| 251 | + "cache_path": cache_path | ||
| 252 | + }, | ||
| 253 | + "result": result_no_content, | ||
| 254 | + "live_record": live_record | ||
| 255 | + } | ||
| 256 | + if cache_path is not None: | ||
| 257 | + os.makedirs(Path(cache_path).parent, exist_ok=True) | ||
| 258 | + with open(cache_path, 'w', encoding='utf-8') as f: | ||
| 259 | + f.write(json.dumps(record, ensure_ascii=False, indent=4)) | ||
| 260 | return result_no_content | 260 | return result_no_content |
| 1 | -import json | ||
| 2 | -import os.path | ||
| 3 | -from pathlib import Path | ||
| 4 | - | ||
| 5 | -from langchain_core.messages import SystemMessage, HumanMessage | ||
| 6 | -from langchain_openai import ChatOpenAI | ||
| 7 | - | ||
| 8 | -try: | ||
| 9 | - from .llm_image import Video2Frame | ||
| 10 | -except: | ||
| 11 | - from llm_image import Video2Frame | ||
| 12 | - | ||
| 13 | - | ||
| 14 | -req_prompt = """ | ||
| 15 | -### 角色设定 | ||
| 16 | -你是一位拥有20年经验的足球赛事视频分析专家。你的任务是结合**视频画面**和**解说音频文本**,精准判断视频片段中是否发生了"有效进球"。 | ||
| 17 | - | ||
| 18 | -### 输入内容 | ||
| 19 | - | ||
| 20 | -1. **视频片段**:可能包含赛场、观众、教练、回放、演播室、宣传片、广告等多种画面。 | ||
| 21 | -2. **解说文本**:该片段对应的实时解说内容(ASR)。 | ||
| 22 | - | ||
| 23 | -### 分析逻辑(思维链) | ||
| 24 | - | ||
| 25 | -请综合以下三个维度的信息进行推理: | ||
| 26 | - | ||
| 27 | -#### 1. 场景维度(非比赛画面过滤 - 最高优先级) | ||
| 28 | - | ||
| 29 | -- **非比赛内容识别**:检查画面是否为**宣传片**、**商业广告**、**纯演播室解说**、**集锦混剪**(无连续比赛画面)或* | ||
| 30 | - *静态图文**。 | ||
| 31 | -- **处理规则**:如果视频内容主要是上述非比赛画面,且没有包含明确的实时进球片段,**直接判定为“无进球”** | ||
| 32 | - ,无需进行后续的进球逻辑分析。 | ||
| 33 | - | ||
| 34 | -#### 2. 视觉维度(寻找关键证据) | ||
| 35 | - | ||
| 36 | -- **核心画面**:足球入网、球在网内静止、裁判指中圈。 | ||
| 37 | -- **行为线索**:进攻方疯狂庆祝、防守方抱头懊恼、全场观众起立欢呼。 | ||
| 38 | -- **画面容忍度**:即使画面主要是观众或教练特写,只要上下文(如切入的进球回放)或行为暗示了进球,也应视为进球。 | ||
| 39 | - | ||
| 40 | -#### 3. 听觉维度(解说语义分析) | ||
| 41 | - | ||
| 42 | -- **进球关键词**:寻找如“球进啦”、“Goal”、“得分”、“世界波”、“破门”、“1比0”等肯定性词汇。 | ||
| 43 | -- **情绪语调**:解说员音量突然升高、语速加快、情绪激动(通常伴随进球发生)。 | ||
| 44 | -- **否定排除**:如果解说提到“越位在先”、“进球无效”、“击中横梁”、“偏出”,则视为未进球。 | ||
| 45 | - | ||
| 46 | -### 判定规则 | ||
| 47 | - | ||
| 48 | -- **判定为“无进球”**: | ||
| 49 | - - **画面为宣传片、广告、纯演播室或其他非比赛实时内容;** | ||
| 50 | - - 视觉显示未进(偏出/被扑/中柱); | ||
| 51 | - - 解说明确表示未进或进球无效; | ||
| 52 | - - 画面与解说均无进球迹象(如普通传球、界外球)。 | ||
| 53 | -- **判定为“进球”**: | ||
| 54 | - - 画面为比赛内容,且视觉清晰显示进球; | ||
| 55 | - - **或** 画面为比赛内容,视觉模糊但解说员明确喊出“球进了”且情绪激动; | ||
| 56 | - - **或** 画面显示庆祝/回放,配合解说确认进球。 | ||
| 57 | - | ||
| 58 | -### 输出要求 | ||
| 59 | - | ||
| 60 | -请仅输出一个JSON格式的结果,不要输出任何分析过程。不要包含 markdown 标记(如 ```json ... ```),不要包含任何解释或额外文本。 | ||
| 61 | -格式如下: | ||
| 62 | -{ | ||
| 63 | -"event_name": "进球" 或 "无进球", | ||
| 64 | -"description": "判定理由,若是宣传片请直接注明" | ||
| 65 | -}""" | ||
| 66 | - | ||
| 67 | - | ||
| 68 | -class FootballReplayVideoEvent: | ||
| 69 | - def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key', cache_dir:str=None, save_frames_enable:bool=False): | ||
| 70 | - self.base_url = base_url | ||
| 71 | - self.model = model | ||
| 72 | - self.temperature = temperature | ||
| 73 | - self.api_key = api_key | ||
| 74 | - # self.model = ChatOllama(base_url="http://192.168.1.59:11434", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 75 | - # keep_alive=-1, reasoning=False) | ||
| 76 | - # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 77 | - # api_key='no_key') | ||
| 78 | - self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | ||
| 79 | - extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 80 | - | ||
| 81 | - | ||
| 82 | - self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | ||
| 83 | - | ||
| 84 | - def video_event(self, replay_pack: dict, asr_text: str = '无', cache_dir=None): | ||
| 85 | - replay_video_path = replay_pack.get("url", None) | ||
| 86 | - | ||
| 87 | - cache_path = os.path.join(cache_dir, 'video_event.json') | ||
| 88 | - if cache_path is not None and os.path.exists(cache_path): | ||
| 89 | - with open(cache_path, 'r', encoding='utf-8') as f: | ||
| 90 | - return json.loads(f.read()) | ||
| 91 | - | ||
| 92 | - event_start = replay_pack.get("start_utc", None) | ||
| 93 | - event_end = replay_pack.get("end_utc", None) | ||
| 94 | - contents = self.video2frame.to_llm_contents(replay_video_path, | ||
| 95 | - cache=cache_dir, | ||
| 96 | - fps=2, | ||
| 97 | - start=event_start, | ||
| 98 | - end=event_end, | ||
| 99 | - roi=None, | ||
| 100 | - max_px_area=400_000, | ||
| 101 | - prompt_start="\n【回放片段信息】\n", | ||
| 102 | - prompt_end=f"\n回放解说内容:{asr_text}\n" | ||
| 103 | - ) | ||
| 104 | - | ||
| 105 | - system_message = SystemMessage(content=req_prompt) | ||
| 106 | - video_message = HumanMessage(content=contents) | ||
| 107 | - asr_message = HumanMessage(content=f"解说内容:{asr_text}") | ||
| 108 | - result = self.model.invoke([system_message, video_message, asr_message]).content | ||
| 109 | - try: | ||
| 110 | - result_json = json.loads(result) | ||
| 111 | - except json.JSONDecodeError: | ||
| 112 | - result_json = json.loads(result.replace("```json", "").replace("```", "")) | ||
| 113 | - if cache_path is not None: | ||
| 114 | - os.makedirs(Path(cache_path).parent, exist_ok=True) | ||
| 115 | - with open(cache_path, 'w', encoding='utf-8') as f: | ||
| 116 | - f.write(result) | ||
| 117 | - return result_json | ||
| 118 | - | ||
| 119 | - | ||
| 120 | -if __name__ == "__main__": | ||
| 121 | - from aabd.base.patched_logging import init_logging | ||
| 122 | - import os | ||
| 123 | - os.environ["APP_LOG_TYPE"] = "console" | ||
| 124 | - init_logging() | ||
| 125 | - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", | ||
| 126 | - model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 127 | - temperature=0.7, | ||
| 128 | - cache_dir="/root/lzw/tmp_0518_replay_cache", | ||
| 129 | - save_frames_enable=True | ||
| 130 | - ) | ||
| 131 | - replay_pack = {"url": "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8", | ||
| 132 | - "start_utc": 0, | ||
| 133 | - "end_utc": 999999999, | ||
| 134 | - "asr_text": '无' | ||
| 135 | - } | ||
| 136 | - | ||
| 137 | - replay_goals = fbrv.video_event(replay_pack, cache_path="/root/lzw/tmp_0518_replay_cache") | 1 | +import json |
| 2 | +import os.path | ||
| 3 | +from pathlib import Path | ||
| 4 | + | ||
| 5 | +from langchain_core.messages import SystemMessage, HumanMessage | ||
| 6 | +from langchain_openai import ChatOpenAI | ||
| 7 | + | ||
| 8 | +try: | ||
| 9 | + from .llm_image import Video2Frame | ||
| 10 | +except: | ||
| 11 | + from llm_image import Video2Frame | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +req_prompt = """ | ||
| 15 | +### 角色设定 | ||
| 16 | +你是一位拥有20年经验的足球赛事视频分析专家。你的任务是结合**视频画面**和**解说音频文本**,精准判断视频片段中是否发生了"有效进球"。 | ||
| 17 | + | ||
| 18 | +### 输入内容 | ||
| 19 | + | ||
| 20 | +1. **视频片段**:可能包含赛场、观众、教练、回放、演播室、宣传片、广告等多种画面。 | ||
| 21 | +2. **解说文本**:该片段对应的实时解说内容(ASR)。 | ||
| 22 | + | ||
| 23 | +### 分析逻辑(思维链) | ||
| 24 | + | ||
| 25 | +请综合以下三个维度的信息进行推理: | ||
| 26 | + | ||
| 27 | +#### 1. 场景维度(非比赛画面过滤 - 最高优先级) | ||
| 28 | + | ||
| 29 | +- **非比赛内容识别**:检查画面是否为**宣传片**、**商业广告**、**纯演播室解说**、**集锦混剪**(无连续比赛画面)或* | ||
| 30 | + *静态图文**。 | ||
| 31 | +- **处理规则**:如果视频内容主要是上述非比赛画面,且没有包含明确的实时进球片段,**直接判定为“无进球”** | ||
| 32 | + ,无需进行后续的进球逻辑分析。 | ||
| 33 | + | ||
| 34 | +#### 2. 视觉维度(寻找关键证据) | ||
| 35 | + | ||
| 36 | +- **核心画面**:足球入网、球在网内静止、裁判指中圈。 | ||
| 37 | +- **行为线索**:进攻方疯狂庆祝、防守方抱头懊恼、全场观众起立欢呼。 | ||
| 38 | +- **画面容忍度**:即使画面主要是观众或教练特写,只要上下文(如切入的进球回放)或行为暗示了进球,也应视为进球。 | ||
| 39 | + | ||
| 40 | +#### 3. 听觉维度(解说语义分析) | ||
| 41 | + | ||
| 42 | +- **进球关键词**:寻找如“球进啦”、“Goal”、“得分”、“世界波”、“破门”、“1比0”等肯定性词汇。 | ||
| 43 | +- **情绪语调**:解说员音量突然升高、语速加快、情绪激动(通常伴随进球发生)。 | ||
| 44 | +- **否定排除**:如果解说提到“越位在先”、“进球无效”、“击中横梁”、“偏出”,则视为未进球。 | ||
| 45 | + | ||
| 46 | +### 判定规则 | ||
| 47 | + | ||
| 48 | +- **判定为“无进球”**: | ||
| 49 | + - **画面为宣传片、广告、纯演播室或其他非比赛实时内容;** | ||
| 50 | + - 视觉显示未进(偏出/被扑/中柱); | ||
| 51 | + - 解说明确表示未进或进球无效; | ||
| 52 | + - 画面与解说均无进球迹象(如普通传球、界外球)。 | ||
| 53 | +- **判定为“进球”**: | ||
| 54 | + - 画面为比赛内容,且视觉清晰显示进球; | ||
| 55 | + - **或** 画面为比赛内容,视觉模糊但解说员明确喊出“球进了”且情绪激动; | ||
| 56 | + - **或** 画面显示庆祝/回放,配合解说确认进球。 | ||
| 57 | + | ||
| 58 | +### 输出要求 | ||
| 59 | + | ||
| 60 | +请仅输出一个JSON格式的结果,不要输出任何分析过程。不要包含 markdown 标记(如 ```json ... ```),不要包含任何解释或额外文本。 | ||
| 61 | +格式如下: | ||
| 62 | +{ | ||
| 63 | +"event_name": "进球" 或 "无进球", | ||
| 64 | +"description": "判定理由,若是宣传片请直接注明" | ||
| 65 | +}""" | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +class FootballReplayVideoEvent: | ||
| 69 | + def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key', cache_dir:str=None, save_frames_enable:bool=False): | ||
| 70 | + self.base_url = base_url | ||
| 71 | + self.model = model | ||
| 72 | + self.temperature = temperature | ||
| 73 | + self.api_key = api_key | ||
| 74 | + # self.model = ChatOllama(base_url="http://192.168.1.59:11434", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 75 | + # keep_alive=-1, reasoning=False) | ||
| 76 | + # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | ||
| 77 | + # api_key='no_key') | ||
| 78 | + self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | ||
| 79 | + extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 80 | + | ||
| 81 | + | ||
| 82 | + self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | ||
| 83 | + | ||
| 84 | + def video_event(self, replay_pack: dict, asr_text: str = '无', cache_dir=None): | ||
| 85 | + replay_video_path = replay_pack.get("url", None) | ||
| 86 | + | ||
| 87 | + cache_path = os.path.join(cache_dir, 'video_event.json') | ||
| 88 | + if cache_path is not None and os.path.exists(cache_path): | ||
| 89 | + with open(cache_path, 'r', encoding='utf-8') as f: | ||
| 90 | + return json.loads(f.read()) | ||
| 91 | + | ||
| 92 | + event_start = replay_pack.get("start_utc", None) | ||
| 93 | + event_end = replay_pack.get("end_utc", None) | ||
| 94 | + contents = self.video2frame.to_llm_contents(replay_video_path, | ||
| 95 | + cache=cache_dir, | ||
| 96 | + fps=2, | ||
| 97 | + start=event_start, | ||
| 98 | + end=event_end, | ||
| 99 | + roi=None, | ||
| 100 | + max_px_area=400_000, | ||
| 101 | + prompt_start="\n【回放片段信息】\n", | ||
| 102 | + prompt_end=f"\n回放解说内容:{asr_text}\n" | ||
| 103 | + ) | ||
| 104 | + | ||
| 105 | + system_message = SystemMessage(content=req_prompt) | ||
| 106 | + video_message = HumanMessage(content=contents) | ||
| 107 | + asr_message = HumanMessage(content=f"解说内容:{asr_text}") | ||
| 108 | + result = self.model.invoke([system_message, video_message, asr_message]).content | ||
| 109 | + try: | ||
| 110 | + result_json = json.loads(result) | ||
| 111 | + except json.JSONDecodeError: | ||
| 112 | + result_json = json.loads(result.replace("```json", "").replace("```", "")) | ||
| 113 | + if cache_path is not None: | ||
| 114 | + os.makedirs(Path(cache_path).parent, exist_ok=True) | ||
| 115 | + with open(cache_path, 'w', encoding='utf-8') as f: | ||
| 116 | + f.write(result) | ||
| 117 | + return result_json | ||
| 118 | + | ||
| 119 | + | ||
| 120 | +if __name__ == "__main__": | ||
| 121 | + from aabd.base.patched_logging import init_logging | ||
| 122 | + import os | ||
| 123 | + os.environ["APP_LOG_TYPE"] = "console" | ||
| 124 | + init_logging() | ||
| 125 | + fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", | ||
| 126 | + model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 127 | + temperature=0.7, | ||
| 128 | + cache_dir="/root/lzw/tmp_0518_replay_cache", | ||
| 129 | + save_frames_enable=True | ||
| 130 | + ) | ||
| 131 | + replay_pack = {"url": "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8", | ||
| 132 | + "start_utc": 0, | ||
| 133 | + "end_utc": 999999999, | ||
| 134 | + "asr_text": '无' | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + replay_goals = fbrv.video_event(replay_pack, cache_path="/root/lzw/tmp_0518_replay_cache") | ||
| 138 | print(replay_goals) | 138 | print(replay_goals) |
| 1 | -import base64 | ||
| 2 | -import shutil | ||
| 3 | -import subprocess | ||
| 4 | -import sys | ||
| 5 | -from pathlib import Path | ||
| 6 | -from typing import Optional | ||
| 7 | -from urllib.parse import urlparse | ||
| 8 | -import cv2 | ||
| 9 | -from aabd.stream_chain import video2frames | ||
| 10 | -import json | ||
| 11 | -import logging | ||
| 12 | -import hashlib | ||
| 13 | - | ||
| 14 | -logger = logging.getLogger(__name__) | ||
| 15 | - | ||
| 16 | -_IS_ABSOLUTE_UTC_THRESHOLD = 1_000_000_000 | ||
| 17 | - | ||
| 18 | - | ||
| 19 | -def download_to_mp4(url: str, output_path: str, duration: Optional[int] = None) -> None: | ||
| 20 | - cmd = ["ffmpeg", "-y", "-fflags", "+discardcorrupt", "-i", url] | ||
| 21 | - if duration is not None: | ||
| 22 | - cmd.extend(["-t", str(duration)]) | ||
| 23 | - cmd.extend(["-c", "copy", "-bsf:a", "aac_adtstoasc", "-movflags", "+faststart", output_path]) | ||
| 24 | - result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") | ||
| 25 | - if result.returncode != 0: | ||
| 26 | - raise RuntimeError(f"ffmpeg 下载失败: {result.stderr}") | ||
| 27 | - | ||
| 28 | - | ||
| 29 | -def resize_frame(frame, max_px_area): | ||
| 30 | - if max_px_area is None: | ||
| 31 | - return frame | ||
| 32 | - h, w = frame.shape[:2] | ||
| 33 | - area = h * w | ||
| 34 | - if area > max_px_area: | ||
| 35 | - scale = (max_px_area / area) ** 0.5 | ||
| 36 | - new_w = int(w * scale) | ||
| 37 | - new_h = int(h * scale) | ||
| 38 | - frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA) | ||
| 39 | - return frame | ||
| 40 | - | ||
| 41 | - | ||
| 42 | -class Video2Frame: | ||
| 43 | - def __init__(self, cache_dir, save_frames_enable=False): | ||
| 44 | - self.cache_dir = Path(cache_dir) | ||
| 45 | - self.save_frames_enable = save_frames_enable | ||
| 46 | - | ||
| 47 | - def get_root_path(self, url, cache=None): | ||
| 48 | - if cache is None: | ||
| 49 | - parsed_url = urlparse(url) | ||
| 50 | - raw = f"{parsed_url.scheme}/{parsed_url.netloc}{parsed_url.path}" | ||
| 51 | - cache = hashlib.md5(raw.encode('utf-8')).hexdigest() | ||
| 52 | - return self.cache_dir / cache | ||
| 53 | - | ||
| 54 | - def iter_mp4(self, file, start, end, fps): | ||
| 55 | - | ||
| 56 | - if (start is not None and start > _IS_ABSOLUTE_UTC_THRESHOLD) or ( | ||
| 57 | - end is not None and end > _IS_ABSOLUTE_UTC_THRESHOLD): | ||
| 58 | - video_start_time = 0 | ||
| 59 | - with video2frames.AVAnyKeyStreamDecoder(file, sei_enable=True, tqdm_enable=False) as (sd, _): | ||
| 60 | - for frame in sd: | ||
| 61 | - if start is not None: | ||
| 62 | - video_start_time = start - frame.get('src_frame_time') | ||
| 63 | - if end is not None: | ||
| 64 | - video_end_time = end - frame.get('src_frame_time') or 0 | ||
| 65 | - break | ||
| 66 | - else: | ||
| 67 | - video_start_time = start if start is not None else 0 | ||
| 68 | - video_end_time = end if end is not None else sys.maxsize | ||
| 69 | - | ||
| 70 | - with video2frames.AVAnyKeyStreamDecoder(file, start=video_start_time, end=video_end_time, control_type='time', | ||
| 71 | - sei_enable=True, max_fps=fps, frame_type='numpy_bgr') as (sd, _): | ||
| 72 | - yield from sd | ||
| 73 | - | ||
| 74 | - def to_frames(self, url, cache=None, fps=None, start=None, end=None, roi=None, max_px_area=None) -> list: | ||
| 75 | - video_root_path = self.get_root_path(url, cache) | ||
| 76 | - | ||
| 77 | - download_video_path = video_root_path / "video.mp4" | ||
| 78 | - | ||
| 79 | - if not download_video_path.exists(): | ||
| 80 | - logger.info(f"download_video: {url} -> {download_video_path}") | ||
| 81 | - video_root_path.mkdir(parents=True, exist_ok=True) | ||
| 82 | - download_to_mp4(url, str(download_video_path), duration=None) | ||
| 83 | - logger.info(f"video_path: {download_video_path}") | ||
| 84 | - cache_dir = video_root_path / 'caches' / f"{start}-{end}-{'_'.join(roi) if roi else 'None'}-{max_px_area}" | ||
| 85 | - cache_frames = cache_dir / "frames" | ||
| 86 | - cache_name = cache_dir / "data.json" | ||
| 87 | - | ||
| 88 | - if cache_name.exists(): | ||
| 89 | - logger.info(f"use_cache: {cache_name}") | ||
| 90 | - # return json.load(cache_name.open('r')) | ||
| 91 | - with cache_name.open('r', encoding='utf-8') as f: | ||
| 92 | - return json.load(f) | ||
| 93 | - else: | ||
| 94 | - cache_dir.mkdir(parents=True, exist_ok=True) | ||
| 95 | - data = { | ||
| 96 | - 'fps': fps, | ||
| 97 | - 'frames': [] | ||
| 98 | - } | ||
| 99 | - if self.save_frames_enable: | ||
| 100 | - cache_frames.mkdir(parents=True, exist_ok=True) | ||
| 101 | - for f in self.iter_mp4(str(download_video_path), start, end, fps): | ||
| 102 | - frame = f['frame'] | ||
| 103 | - time = f['src_frame_time'] | ||
| 104 | - fps = f['fps'] | ||
| 105 | - data['fps'] = fps | ||
| 106 | - if roi is not None: | ||
| 107 | - x1, y1, x2, y2 = roi | ||
| 108 | - frame = frame[y1:y2, x1:x2] | ||
| 109 | - if max_px_area is not None: | ||
| 110 | - frame = resize_frame(frame, max_px_area) | ||
| 111 | - | ||
| 112 | - _, buffer = cv2.imencode('.jpg', frame) | ||
| 113 | - b64_str = base64.b64encode(buffer).decode('utf-8') | ||
| 114 | - | ||
| 115 | - data['frames'].append({'time': time, 'image': b64_str}) | ||
| 116 | - | ||
| 117 | - if self.save_frames_enable: | ||
| 118 | - frame_path = cache_frames / f"{time:015d}.jpg" | ||
| 119 | - cv2.imwrite(frame_path, frame) | ||
| 120 | - | ||
| 121 | - cache_name.parent.mkdir(parents=True, exist_ok=True) | ||
| 122 | - # json.dump(data, cache_name.open('w'), indent=4) | ||
| 123 | - with cache_name.open('w', encoding='utf-8') as f: | ||
| 124 | - json.dump(data, f, indent=4) | ||
| 125 | - logger.info(f"save_cache: {cache_name}") | ||
| 126 | - return data | ||
| 127 | - | ||
| 128 | - def to_llm_contents(self, url, cache=None, fps=None, start=None, end=None, roi=None, max_px_area=None, | ||
| 129 | - prompt_start=None, | ||
| 130 | - prompt_end=None): | ||
| 131 | - data = self.to_frames(url, cache, fps, start, end, roi, max_px_area) | ||
| 132 | - fps = data['fps'] | ||
| 133 | - frames = data['frames'] | ||
| 134 | - | ||
| 135 | - contents = [] | ||
| 136 | - if prompt_start is not None: | ||
| 137 | - contents.append({"type": "text", "text": prompt_start}) | ||
| 138 | - video_prompt = ( | ||
| 139 | - f"以下是从视频中按时间顺序提取的 {len(frames)} 帧画面,fps={fps},请将它们视为一个连续的视频进行分析。" | ||
| 140 | - ) | ||
| 141 | - contents.append({"type": "text", "text": video_prompt}) | ||
| 142 | - | ||
| 143 | - for frame in frames: | ||
| 144 | - contents.append({ | ||
| 145 | - "type": "image_url", | ||
| 146 | - "image_url": { | ||
| 147 | - "url": f"data:image/jpeg;base64,{frame['image']}" | ||
| 148 | - } | ||
| 149 | - }) | ||
| 150 | - if prompt_end is not None: | ||
| 151 | - contents.append({"type": "text", "text": prompt_end}) | ||
| 152 | - return contents | ||
| 153 | - | ||
| 154 | - | ||
| 155 | -if __name__ == '__main__': | ||
| 156 | - from aabd.base.patched_logging import init_logging | ||
| 157 | - import os | ||
| 158 | - os.environ["APP_LOG_TYPE"] = "console" | ||
| 159 | - init_logging() | ||
| 160 | - # url = "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8" | ||
| 161 | - url = rf"/root/lzw/finished/69dd5845dd0412067b8d5587-auto-1776074760653/live/videos/00-14-09-052.mp4" | ||
| 162 | - caceh_dir = r"/root/lzw/aigc-embedding-service/src/football_replay_match/core" | ||
| 163 | - vf = Video2Frame(caceh_dir, save_frames_enable=True) | ||
| 164 | - # vf = Video2Frame(rf'D:\Code\migu\aigc-embedding-service\src\football_replay_match\core', save_frames_enable=True) | ||
| 165 | - # a = vf.to_llm_contents(url, fps=2, start=1777444502543, end=1777444531063, max_px_area=1920 * 1080 // 6) | ||
| 166 | - a = vf.to_llm_contents(url, fps=2, start=0, end=10_000, max_px_area=1920 * 1080 // 6) | ||
| 167 | - from langchain_openai import ChatOpenAI | ||
| 168 | - from langchain_core.messages import SystemMessage, HumanMessage | ||
| 169 | - | ||
| 170 | - model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 171 | - temperature=0.7, api_key='no_key', | ||
| 172 | - extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 173 | - | ||
| 174 | - result = model.invoke([SystemMessage(content="描述一下视频的内容。"), HumanMessage(content=a)]) | ||
| 175 | - print(result) | 1 | +import base64 |
| 2 | +import shutil | ||
| 3 | +import subprocess | ||
| 4 | +import sys | ||
| 5 | +from pathlib import Path | ||
| 6 | +from typing import Optional | ||
| 7 | +from urllib.parse import urlparse | ||
| 8 | +import cv2 | ||
| 9 | +from aabd.stream_chain import video2frames | ||
| 10 | +import json | ||
| 11 | +import logging | ||
| 12 | +import hashlib | ||
| 13 | + | ||
| 14 | +logger = logging.getLogger(__name__) | ||
| 15 | + | ||
| 16 | +_IS_ABSOLUTE_UTC_THRESHOLD = 1_000_000_000 | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def download_to_mp4(url: str, output_path: str, duration: Optional[int] = None) -> None: | ||
| 20 | + cmd = ["ffmpeg", "-y", "-fflags", "+discardcorrupt", "-i", url] | ||
| 21 | + if duration is not None: | ||
| 22 | + cmd.extend(["-t", str(duration)]) | ||
| 23 | + cmd.extend(["-c", "copy", "-bsf:a", "aac_adtstoasc", "-movflags", "+faststart", output_path]) | ||
| 24 | + result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") | ||
| 25 | + if result.returncode != 0: | ||
| 26 | + raise RuntimeError(f"ffmpeg 下载失败: {result.stderr}") | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def resize_frame(frame, max_px_area): | ||
| 30 | + if max_px_area is None: | ||
| 31 | + return frame | ||
| 32 | + h, w = frame.shape[:2] | ||
| 33 | + area = h * w | ||
| 34 | + if area > max_px_area: | ||
| 35 | + scale = (max_px_area / area) ** 0.5 | ||
| 36 | + new_w = int(w * scale) | ||
| 37 | + new_h = int(h * scale) | ||
| 38 | + frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA) | ||
| 39 | + return frame | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +class Video2Frame: | ||
| 43 | + def __init__(self, cache_dir, save_frames_enable=False): | ||
| 44 | + self.cache_dir = Path(cache_dir) | ||
| 45 | + self.save_frames_enable = save_frames_enable | ||
| 46 | + | ||
| 47 | + def get_root_path(self, url, cache=None): | ||
| 48 | + if cache is None: | ||
| 49 | + parsed_url = urlparse(url) | ||
| 50 | + raw = f"{parsed_url.scheme}/{parsed_url.netloc}{parsed_url.path}" | ||
| 51 | + cache = hashlib.md5(raw.encode('utf-8')).hexdigest() | ||
| 52 | + return self.cache_dir / cache | ||
| 53 | + | ||
| 54 | + def iter_mp4(self, file, start, end, fps): | ||
| 55 | + | ||
| 56 | + if (start is not None and start > _IS_ABSOLUTE_UTC_THRESHOLD) or ( | ||
| 57 | + end is not None and end > _IS_ABSOLUTE_UTC_THRESHOLD): | ||
| 58 | + video_start_time = 0 | ||
| 59 | + with video2frames.AVAnyKeyStreamDecoder(file, sei_enable=True, tqdm_enable=False) as (sd, _): | ||
| 60 | + for frame in sd: | ||
| 61 | + if start is not None: | ||
| 62 | + video_start_time = start - frame.get('src_frame_time') | ||
| 63 | + if end is not None: | ||
| 64 | + video_end_time = end - frame.get('src_frame_time') or 0 | ||
| 65 | + break | ||
| 66 | + else: | ||
| 67 | + video_start_time = start if start is not None else 0 | ||
| 68 | + video_end_time = end if end is not None else sys.maxsize | ||
| 69 | + | ||
| 70 | + with video2frames.AVAnyKeyStreamDecoder(file, start=video_start_time, end=video_end_time, control_type='time', | ||
| 71 | + sei_enable=True, max_fps=fps, frame_type='numpy_bgr') as (sd, _): | ||
| 72 | + yield from sd | ||
| 73 | + | ||
| 74 | + def to_frames(self, url, cache=None, fps=None, start=None, end=None, roi=None, max_px_area=None) -> list: | ||
| 75 | + video_root_path = self.get_root_path(url, cache) | ||
| 76 | + | ||
| 77 | + download_video_path = video_root_path / "video.mp4" | ||
| 78 | + | ||
| 79 | + if not download_video_path.exists(): | ||
| 80 | + logger.info(f"download_video: {url} -> {download_video_path}") | ||
| 81 | + video_root_path.mkdir(parents=True, exist_ok=True) | ||
| 82 | + download_to_mp4(url, str(download_video_path), duration=None) | ||
| 83 | + logger.info(f"video_path: {download_video_path}") | ||
| 84 | + cache_dir = video_root_path / 'caches' / f"{start}-{end}-{'_'.join(roi) if roi else 'None'}-{max_px_area}" | ||
| 85 | + cache_frames = cache_dir / "frames" | ||
| 86 | + cache_name = cache_dir / "data.json" | ||
| 87 | + | ||
| 88 | + if cache_name.exists(): | ||
| 89 | + logger.info(f"use_cache: {cache_name}") | ||
| 90 | + # return json.load(cache_name.open('r')) | ||
| 91 | + with cache_name.open('r', encoding='utf-8') as f: | ||
| 92 | + return json.load(f) | ||
| 93 | + else: | ||
| 94 | + cache_dir.mkdir(parents=True, exist_ok=True) | ||
| 95 | + data = { | ||
| 96 | + 'fps': fps, | ||
| 97 | + 'frames': [] | ||
| 98 | + } | ||
| 99 | + if self.save_frames_enable: | ||
| 100 | + cache_frames.mkdir(parents=True, exist_ok=True) | ||
| 101 | + for f in self.iter_mp4(str(download_video_path), start, end, fps): | ||
| 102 | + frame = f['frame'] | ||
| 103 | + time = f['src_frame_time'] | ||
| 104 | + fps = f['fps'] | ||
| 105 | + data['fps'] = fps | ||
| 106 | + if roi is not None: | ||
| 107 | + x1, y1, x2, y2 = roi | ||
| 108 | + frame = frame[y1:y2, x1:x2] | ||
| 109 | + if max_px_area is not None: | ||
| 110 | + frame = resize_frame(frame, max_px_area) | ||
| 111 | + | ||
| 112 | + _, buffer = cv2.imencode('.jpg', frame) | ||
| 113 | + b64_str = base64.b64encode(buffer).decode('utf-8') | ||
| 114 | + | ||
| 115 | + data['frames'].append({'time': time, 'image': b64_str}) | ||
| 116 | + | ||
| 117 | + if self.save_frames_enable: | ||
| 118 | + frame_path = cache_frames / f"{time:015d}.jpg" | ||
| 119 | + cv2.imwrite(frame_path, frame) | ||
| 120 | + | ||
| 121 | + cache_name.parent.mkdir(parents=True, exist_ok=True) | ||
| 122 | + # json.dump(data, cache_name.open('w'), indent=4) | ||
| 123 | + with cache_name.open('w', encoding='utf-8') as f: | ||
| 124 | + json.dump(data, f, indent=4) | ||
| 125 | + logger.info(f"save_cache: {cache_name}") | ||
| 126 | + return data | ||
| 127 | + | ||
| 128 | + def to_llm_contents(self, url, cache=None, fps=None, start=None, end=None, roi=None, max_px_area=None, | ||
| 129 | + prompt_start=None, | ||
| 130 | + prompt_end=None): | ||
| 131 | + data = self.to_frames(url, cache, fps, start, end, roi, max_px_area) | ||
| 132 | + fps = data['fps'] | ||
| 133 | + frames = data['frames'] | ||
| 134 | + | ||
| 135 | + contents = [] | ||
| 136 | + if prompt_start is not None: | ||
| 137 | + contents.append({"type": "text", "text": prompt_start}) | ||
| 138 | + video_prompt = ( | ||
| 139 | + f"以下是从视频中按时间顺序提取的 {len(frames)} 帧画面,fps={fps},请将它们视为一个连续的视频进行分析。" | ||
| 140 | + ) | ||
| 141 | + contents.append({"type": "text", "text": video_prompt}) | ||
| 142 | + | ||
| 143 | + for frame in frames: | ||
| 144 | + contents.append({ | ||
| 145 | + "type": "image_url", | ||
| 146 | + "image_url": { | ||
| 147 | + "url": f"data:image/jpeg;base64,{frame['image']}" | ||
| 148 | + } | ||
| 149 | + }) | ||
| 150 | + if prompt_end is not None: | ||
| 151 | + contents.append({"type": "text", "text": prompt_end}) | ||
| 152 | + return contents | ||
| 153 | + | ||
| 154 | + | ||
| 155 | +if __name__ == '__main__': | ||
| 156 | + from aabd.base.patched_logging import init_logging | ||
| 157 | + import os | ||
| 158 | + os.environ["APP_LOG_TYPE"] = "console" | ||
| 159 | + init_logging() | ||
| 160 | + # url = "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8" | ||
| 161 | + url = rf"/root/lzw/finished/69dd5845dd0412067b8d5587-auto-1776074760653/live/videos/00-14-09-052.mp4" | ||
| 162 | + caceh_dir = r"/root/lzw/aigc-embedding-service/src/football_replay_match/core" | ||
| 163 | + vf = Video2Frame(caceh_dir, save_frames_enable=True) | ||
| 164 | + # vf = Video2Frame(rf'D:\Code\migu\aigc-embedding-service\src\football_replay_match\core', save_frames_enable=True) | ||
| 165 | + # a = vf.to_llm_contents(url, fps=2, start=1777444502543, end=1777444531063, max_px_area=1920 * 1080 // 6) | ||
| 166 | + a = vf.to_llm_contents(url, fps=2, start=0, end=10_000, max_px_area=1920 * 1080 // 6) | ||
| 167 | + from langchain_openai import ChatOpenAI | ||
| 168 | + from langchain_core.messages import SystemMessage, HumanMessage | ||
| 169 | + | ||
| 170 | + model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 171 | + temperature=0.7, api_key='no_key', | ||
| 172 | + extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 173 | + | ||
| 174 | + result = model.invoke([SystemMessage(content="描述一下视频的内容。"), HumanMessage(content=a)]) | ||
| 175 | + print(result) |
| 1 | -from football_replay_match_live import FootballReplayMatchLive | ||
| 2 | -# from qwen_asr_util import QwenAsr | ||
| 3 | -from football_replay_video_event_by_llm import FootballReplayVideoEvent | ||
| 4 | -import os | ||
| 5 | -import json | ||
| 6 | - | ||
| 7 | - | ||
| 8 | -def batch_match(): | ||
| 9 | - cache_dir = "/root/lzw/tmp_0518_replay_cache03" | ||
| 10 | - replay_match_live = FootballReplayMatchLive(base_url="http://192.168.1.59:11434/v1", | ||
| 11 | - model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 12 | - temperature=0.7, | ||
| 13 | - cache_dir=cache_dir, | ||
| 14 | - save_frames_enable=True | ||
| 15 | - ) | ||
| 16 | - # qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B") | ||
| 17 | - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", | ||
| 18 | - model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 19 | - temperature=0.7, | ||
| 20 | - cache_dir=cache_dir, | ||
| 21 | - save_frames_enable=True | ||
| 22 | - ) | ||
| 23 | - | ||
| 24 | - videos_dir = rf"/root/lzw/finished" | ||
| 25 | - for video_name in sorted(os.listdir(videos_dir)): | ||
| 26 | - | ||
| 27 | - live_dir = os.path.join(videos_dir, video_name, 'live') | ||
| 28 | - print(f"live_dir: {live_dir}") | ||
| 29 | - | ||
| 30 | - live_video_dir = os.path.join(live_dir, 'videos') | ||
| 31 | - live_asr_dir = os.path.join(live_dir, 'asr') | ||
| 32 | - | ||
| 33 | - live_packs = [] | ||
| 34 | - for live_video_name in sorted(os.listdir(live_video_dir)): | ||
| 35 | - live_video_path = os.path.join(live_video_dir, live_video_name) | ||
| 36 | - live_asr_path = os.path.join(live_asr_dir, f"{live_video_name}.txt") | ||
| 37 | - # live_asr_text = qwen_asr.asr(live_video_path, cache_path=live_asr_path) | ||
| 38 | - live_asr_text = '' | ||
| 39 | - live_packs.append({ | ||
| 40 | - "video_id": os.path.basename(live_video_path), | ||
| 41 | - "url": live_video_path, | ||
| 42 | - "asr_text": live_asr_text | ||
| 43 | - }) | ||
| 44 | - | ||
| 45 | - replays_dir = os.path.join(videos_dir, video_name, 'replays') | ||
| 46 | - replay_videos_dir = os.path.join(replays_dir, 'videos') | ||
| 47 | - replay_goals_dir = os.path.join(replays_dir, 'goals') | ||
| 48 | - replay_asr_dir = os.path.join(replays_dir, 'asr') | ||
| 49 | - replay_matches_dir = os.path.join(replays_dir, 'matches') | ||
| 50 | - | ||
| 51 | - for replay_video_name in sorted(os.listdir(replay_videos_dir)): | ||
| 52 | - replay_video_path = os.path.join(replay_videos_dir, replay_video_name) | ||
| 53 | - # replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json") | ||
| 54 | - | ||
| 55 | - # replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.txt") | ||
| 56 | - # replay_match_path = os.path.join(replay_matches_dir, f"{replay_video_name}.json") | ||
| 57 | - | ||
| 58 | - # print(100*"*") | ||
| 59 | - # print(replay_match_path) | ||
| 60 | - replay_match_path = None | ||
| 61 | - | ||
| 62 | - # replay_asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path) | ||
| 63 | - replay_asr_text = '' | ||
| 64 | - replay_pack = {"url": "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8", | ||
| 65 | - "start_utc": 1777444502543, | ||
| 66 | - "end_utc": 1777444531063, | ||
| 67 | - "asr_text": '无' | ||
| 68 | - } | ||
| 69 | - | ||
| 70 | - replay_goals = fbrv.video_event(replay_pack, cache_dir=cache_dir) | ||
| 71 | - if replay_goals['event_name'] == '无进球': | ||
| 72 | - continue | ||
| 73 | - # replay_pack = {"video_path": replay_video_path, "asr_text": replay_asr_text} | ||
| 74 | - | ||
| 75 | - try: | ||
| 76 | - import time | ||
| 77 | - start_time = time.time() | ||
| 78 | - print("Start matching...") | ||
| 79 | - print(f"len(live_packs): {len(live_packs)}") | ||
| 80 | - result = replay_match_live.match_batch(replay_pack, live_packs, max_parallel=2, cache_dir=cache_dir) | ||
| 81 | - end_time = time.time() | ||
| 82 | - print(f"Time taken for matching: {end_time - start_time:.2f} seconds") | ||
| 83 | - print(replay_video_path) | ||
| 84 | - print(result) | ||
| 85 | - break | ||
| 86 | - | ||
| 87 | - except Exception as e: | ||
| 88 | - print(f"Error processing {replay_video_path}: {e}") | ||
| 89 | - | ||
| 90 | - print('-' * 20) | ||
| 91 | - | ||
| 92 | - break | ||
| 93 | - | ||
| 94 | - | ||
| 95 | -if __name__ == '__main__': | ||
| 96 | - batch_match() | 1 | +from football_replay_match_live import FootballReplayMatchLive |
| 2 | +# from qwen_asr_util import QwenAsr | ||
| 3 | +from football_replay_video_event_by_llm import FootballReplayVideoEvent | ||
| 4 | +import os | ||
| 5 | +import json | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +def batch_match(): | ||
| 9 | + cache_dir = "/root/lzw/tmp_0518_replay_cache03" | ||
| 10 | + replay_match_live = FootballReplayMatchLive(base_url="http://192.168.1.59:11434/v1", | ||
| 11 | + model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 12 | + temperature=0.7, | ||
| 13 | + cache_dir=cache_dir, | ||
| 14 | + save_frames_enable=True | ||
| 15 | + ) | ||
| 16 | + # qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B") | ||
| 17 | + fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", | ||
| 18 | + model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 19 | + temperature=0.7, | ||
| 20 | + cache_dir=cache_dir, | ||
| 21 | + save_frames_enable=True | ||
| 22 | + ) | ||
| 23 | + | ||
| 24 | + videos_dir = rf"/root/lzw/finished" | ||
| 25 | + for video_name in sorted(os.listdir(videos_dir)): | ||
| 26 | + | ||
| 27 | + live_dir = os.path.join(videos_dir, video_name, 'live') | ||
| 28 | + print(f"live_dir: {live_dir}") | ||
| 29 | + | ||
| 30 | + live_video_dir = os.path.join(live_dir, 'videos') | ||
| 31 | + live_asr_dir = os.path.join(live_dir, 'asr') | ||
| 32 | + | ||
| 33 | + live_packs = [] | ||
| 34 | + for live_video_name in sorted(os.listdir(live_video_dir)): | ||
| 35 | + live_video_path = os.path.join(live_video_dir, live_video_name) | ||
| 36 | + live_asr_path = os.path.join(live_asr_dir, f"{live_video_name}.txt") | ||
| 37 | + # live_asr_text = qwen_asr.asr(live_video_path, cache_path=live_asr_path) | ||
| 38 | + live_asr_text = '' | ||
| 39 | + live_packs.append({ | ||
| 40 | + "video_id": os.path.basename(live_video_path), | ||
| 41 | + "url": live_video_path, | ||
| 42 | + "asr_text": live_asr_text | ||
| 43 | + }) | ||
| 44 | + | ||
| 45 | + replays_dir = os.path.join(videos_dir, video_name, 'replays') | ||
| 46 | + replay_videos_dir = os.path.join(replays_dir, 'videos') | ||
| 47 | + replay_goals_dir = os.path.join(replays_dir, 'goals') | ||
| 48 | + replay_asr_dir = os.path.join(replays_dir, 'asr') | ||
| 49 | + replay_matches_dir = os.path.join(replays_dir, 'matches') | ||
| 50 | + | ||
| 51 | + for replay_video_name in sorted(os.listdir(replay_videos_dir)): | ||
| 52 | + replay_video_path = os.path.join(replay_videos_dir, replay_video_name) | ||
| 53 | + # replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json") | ||
| 54 | + | ||
| 55 | + # replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.txt") | ||
| 56 | + # replay_match_path = os.path.join(replay_matches_dir, f"{replay_video_name}.json") | ||
| 57 | + | ||
| 58 | + # print(100*"*") | ||
| 59 | + # print(replay_match_path) | ||
| 60 | + replay_match_path = None | ||
| 61 | + | ||
| 62 | + # replay_asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path) | ||
| 63 | + replay_asr_text = '' | ||
| 64 | + replay_pack = {"url": "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8", | ||
| 65 | + "start_utc": 1777444502543, | ||
| 66 | + "end_utc": 1777444531063, | ||
| 67 | + "asr_text": '无' | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + replay_goals = fbrv.video_event(replay_pack, cache_dir=cache_dir) | ||
| 71 | + if replay_goals['event_name'] == '无进球': | ||
| 72 | + continue | ||
| 73 | + # replay_pack = {"video_path": replay_video_path, "asr_text": replay_asr_text} | ||
| 74 | + | ||
| 75 | + try: | ||
| 76 | + import time | ||
| 77 | + start_time = time.time() | ||
| 78 | + print("Start matching...") | ||
| 79 | + print(f"len(live_packs): {len(live_packs)}") | ||
| 80 | + result = replay_match_live.match_batch(replay_pack, live_packs, max_parallel=2, cache_dir=cache_dir) | ||
| 81 | + end_time = time.time() | ||
| 82 | + print(f"Time taken for matching: {end_time - start_time:.2f} seconds") | ||
| 83 | + print(replay_video_path) | ||
| 84 | + print(result) | ||
| 85 | + break | ||
| 86 | + | ||
| 87 | + except Exception as e: | ||
| 88 | + print(f"Error processing {replay_video_path}: {e}") | ||
| 89 | + | ||
| 90 | + print('-' * 20) | ||
| 91 | + | ||
| 92 | + break | ||
| 93 | + | ||
| 94 | + | ||
| 95 | +if __name__ == '__main__': | ||
| 96 | + batch_match() |
| 1 | -from aabd.base.patched_logging import init_logging, get_logger | ||
| 2 | - | ||
| 3 | -init_logging() | ||
| 4 | -logger = get_logger(__name__) | ||
| 5 | -import json | ||
| 6 | -import time | ||
| 7 | - | ||
| 8 | -try: | ||
| 9 | - from config import settings | ||
| 10 | -except: | ||
| 11 | - from .config import settings | ||
| 12 | - | ||
| 13 | - pass | ||
| 14 | -try: | ||
| 15 | - from .core.api import FootballReplayMatch | ||
| 16 | -except: | ||
| 17 | - from core.api import FootballReplayMatch | ||
| 18 | - pass | ||
| 19 | - | ||
| 20 | -from threading import Thread | ||
| 21 | - | ||
| 22 | -from aabd.mq.kafka_client import KafkaMessageIterator, KafkaProducer | ||
| 23 | - | ||
| 24 | - | ||
| 25 | -def get_message_iterator(kafka_config): | ||
| 26 | - return KafkaMessageIterator(bootstrap_servers=kafka_config.get('servers', None), | ||
| 27 | - group_id=kafka_config.get('group_id', None), | ||
| 28 | - topic=kafka_config.get('topic', None), | ||
| 29 | - sasl_plain_username=kafka_config.get('username', None), | ||
| 30 | - sasl_plain_password=kafka_config.get('password', None), | ||
| 31 | - value_deserializer="str") | ||
| 32 | - | ||
| 33 | - | ||
| 34 | -def get_message_producer(kafka_config): | ||
| 35 | - return KafkaProducer(bootstrap_servers=kafka_config.get('servers', None), | ||
| 36 | - sasl_plain_username=kafka_config.get('username', None), | ||
| 37 | - sasl_plain_password=kafka_config.get('password', None)) | ||
| 38 | - | ||
| 39 | - | ||
| 40 | -def get_callback_func(producer, topic): | ||
| 41 | - def callback_func(task_id, call_data): | ||
| 42 | - try: | ||
| 43 | - st = time.time() | ||
| 44 | - producer.send_message_async(topic, call_data, key=task_id, timeout=5) | ||
| 45 | - logger.info(f"sent_message[{task_id}][{time.time() - st:.2f}s]: {call_data}") | ||
| 46 | - except: | ||
| 47 | - logger.exception(f"Error sending message: {call_data}") | ||
| 48 | - | ||
| 49 | - return callback_func | ||
| 50 | - | ||
| 51 | - | ||
| 52 | -def kafka_message_iterator_thread(message_iterator, config, callback_func): | ||
| 53 | - frm = FootballReplayMatch(config) | ||
| 54 | - with message_iterator: | ||
| 55 | - for message in message_iterator: | ||
| 56 | - try: | ||
| 57 | - json_message = json.loads(message) | ||
| 58 | - result = frm.replay_match_event(json_message) | ||
| 59 | - task_id = json_message.get("id") | ||
| 60 | - callback_func(task_id, json.dumps(result, ensure_ascii=False)) | ||
| 61 | - logger.info(f"Processed task_id={task_id}, result={result}") | ||
| 62 | - except Exception: | ||
| 63 | - logger.exception(f"Error processing message: {message}") | ||
| 64 | - | ||
| 65 | - | ||
| 66 | -def main(): | ||
| 67 | - message_iter = get_message_iterator(settings.input_kafka) | ||
| 68 | - kafka_producer = get_message_producer(settings.output_kafka) | ||
| 69 | - | ||
| 70 | - try: | ||
| 71 | - | ||
| 72 | - Thread(target=kafka_message_iterator_thread, | ||
| 73 | - args=(message_iter, settings, | ||
| 74 | - get_callback_func(kafka_producer, settings.output_kafka.get('topic', None)))).start() | ||
| 75 | - | ||
| 76 | - except KeyboardInterrupt: | ||
| 77 | - message_iter.running = False | ||
| 78 | - kafka_producer.close() | ||
| 79 | - | ||
| 80 | - | ||
| 81 | -if __name__ == '__main__': | ||
| 82 | - main() | 1 | +from aabd.base.patched_logging import init_logging, get_logger |
| 2 | + | ||
| 3 | +init_logging() | ||
| 4 | +logger = get_logger(__name__) | ||
| 5 | +import json | ||
| 6 | +import time | ||
| 7 | + | ||
| 8 | +try: | ||
| 9 | + from config import settings | ||
| 10 | +except: | ||
| 11 | + from .config import settings | ||
| 12 | + | ||
| 13 | + pass | ||
| 14 | +try: | ||
| 15 | + from .core.api import FootballReplayMatch | ||
| 16 | +except: | ||
| 17 | + from core.api import FootballReplayMatch | ||
| 18 | + pass | ||
| 19 | + | ||
| 20 | +from threading import Thread | ||
| 21 | + | ||
| 22 | +from aabd.mq.kafka_client import KafkaMessageIterator, KafkaProducer | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +def get_message_iterator(kafka_config): | ||
| 26 | + return KafkaMessageIterator(bootstrap_servers=kafka_config.get('servers', None), | ||
| 27 | + group_id=kafka_config.get('group_id', None), | ||
| 28 | + topic=kafka_config.get('topic', None), | ||
| 29 | + sasl_plain_username=kafka_config.get('username', None), | ||
| 30 | + sasl_plain_password=kafka_config.get('password', None), | ||
| 31 | + value_deserializer="str") | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def get_message_producer(kafka_config): | ||
| 35 | + return KafkaProducer(bootstrap_servers=kafka_config.get('servers', None), | ||
| 36 | + sasl_plain_username=kafka_config.get('username', None), | ||
| 37 | + sasl_plain_password=kafka_config.get('password', None)) | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +def get_callback_func(producer, topic): | ||
| 41 | + def callback_func(task_id, call_data): | ||
| 42 | + try: | ||
| 43 | + st = time.time() | ||
| 44 | + producer.send_message_async(topic, call_data, key=task_id, timeout=5) | ||
| 45 | + logger.info(f"sent_message[{task_id}][{time.time() - st:.2f}s]: {call_data}") | ||
| 46 | + except: | ||
| 47 | + logger.exception(f"Error sending message: {call_data}") | ||
| 48 | + | ||
| 49 | + return callback_func | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +def kafka_message_iterator_thread(message_iterator, config, callback_func): | ||
| 53 | + frm = FootballReplayMatch(config) | ||
| 54 | + with message_iterator: | ||
| 55 | + for message in message_iterator: | ||
| 56 | + try: | ||
| 57 | + json_message = json.loads(message) | ||
| 58 | + result = frm.replay_match_event(json_message) | ||
| 59 | + task_id = json_message.get("id") | ||
| 60 | + callback_func(task_id, json.dumps(result, ensure_ascii=False)) | ||
| 61 | + logger.info(f"Processed task_id={task_id}, result={result}") | ||
| 62 | + except Exception: | ||
| 63 | + logger.exception(f"Error processing message: {message}") | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +def main(): | ||
| 67 | + message_iter = get_message_iterator(settings.input_kafka) | ||
| 68 | + kafka_producer = get_message_producer(settings.output_kafka) | ||
| 69 | + | ||
| 70 | + try: | ||
| 71 | + | ||
| 72 | + Thread(target=kafka_message_iterator_thread, | ||
| 73 | + args=(message_iter, settings, | ||
| 74 | + get_callback_func(kafka_producer, settings.output_kafka.get('topic', None)))).start() | ||
| 75 | + | ||
| 76 | + except KeyboardInterrupt: | ||
| 77 | + message_iter.running = False | ||
| 78 | + kafka_producer.close() | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +if __name__ == '__main__': | ||
| 82 | + main() |
This diff could not be displayed because it is too large.
-
Please register or login to post a comment