Browse Source

增加外呼评级功能

boweniac 2 months ago
parent
commit
252698c01c
40 changed files with 1237 additions and 46 deletions
  1. 0 0
      app/admin/__init__.py
  2. 9 0
      app/admin/api/__init__.py
  3. 19 0
      app/admin/api/auth.py
  4. 74 0
      app/admin/api/intent_org.py
  5. 0 0
      app/admin/crud/__init__.py
  6. 82 0
      app/admin/crud/crud_intent_org.py
  7. 0 0
      app/admin/schema/__init__.py
  8. 10 0
      app/admin/schema/auth.py
  9. 33 0
      app/admin/schema/intent_org.py
  10. 24 0
      app/admin/schema/token.py
  11. 0 0
      app/admin/service/__init__.py
  12. 22 0
      app/admin/service/auth_service.py
  13. 50 0
      app/admin/service/intent_org_service.py
  14. 0 0
      app/call_center/__init__.py
  15. 0 0
      app/call_center/api/__init__.py
  16. 7 0
      app/call_center/api/intent/__init__.py
  17. 96 0
      app/call_center/api/intent/evaluate.py
  18. 0 0
      app/call_center/crud/__init__.py
  19. 120 0
      app/call_center/crud/crud_intent_records.py
  20. 0 0
      app/call_center/schema/__init__.py
  21. 55 0
      app/call_center/schema/intent_records.py
  22. 0 0
      app/call_center/service/__init__.py
  23. 62 0
      app/call_center/service/intent_records_service.py
  24. 5 6
      app/gpt/api/route.py
  25. 0 1
      app/gpt/service/ali_filetrans.py
  26. 8 3
      app/router.py
  27. 0 0
      batch_task/__init__.py
  28. 35 0
      batch_task/batch_task.py
  29. 189 0
      batch_task/update_llm_intent.py
  30. 18 0
      common/dataclasses.py
  31. 6 2
      common/model.py
  32. 116 0
      common/security/jwt_call_center.py
  33. 3 0
      core/conf.py
  34. 74 12
      core/registrar.py
  35. 7 20
      main.py
  36. 55 0
      middleware/jwt_call_center_auth_middleware.py
  37. 27 0
      model/intent_org.py
  38. 30 0
      model/intent_records.py
  39. 1 2
      model/records.py
  40. BIN
      requirements.txt

+ 0 - 0
app/admin/__init__.py


+ 9 - 0
app/admin/api/__init__.py

@@ -0,0 +1,9 @@
+from fastapi import APIRouter
+
+from app.admin.api.intent_org import router as intent_org_router
+from app.admin.api.auth import router as auth_router
+
+v1 = APIRouter(prefix='/admin', tags=['admin'])
+
+v1.include_router(intent_org_router, prefix='/intent_org', tags=['组织'])
+v1.include_router(auth_router, prefix='/auth', tags=['鉴权'])

+ 19 - 0
app/admin/api/auth.py

@@ -0,0 +1,19 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from fastapi import APIRouter
+from app.admin.schema.auth import AuthGenTokenParam
+from app.admin.service.auth_service import auth_service
+from common.response.response_schema import ResponseModel, response_base
+
+router = APIRouter()
+
+@router.post(
+    '/gen_token',
+    summary='获取 token',
+    description=''
+)
+async def get_token(
+    obj: AuthGenTokenParam
+) -> ResponseModel:
+    data = await auth_service.gen_token(obj=obj)
+    return response_base.success(data=data)

+ 74 - 0
app/admin/api/intent_org.py

@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from typing import Annotated
+
+from app.admin.schema.intent_org import CreateIntentOrgParam, GetIntentOrgListDetails, UpdateIntentOrgParam
+from app.admin.service.intent_org_service import intent_org_service
+from common.pagination import DependsPagination, paging_data
+from common.response.response_schema import ResponseModel, response_base
+from common.security.jwt_call_center import DependsJwtAuth
+from common.security.permission import RequestPermission
+from database.db_mysql import CurrentSession
+from fastapi import APIRouter, Depends, Path, Query
+
+router = APIRouter()
+
+
+@router.get('/{pk}', summary='获取 intent_org 详情', dependencies=[DependsJwtAuth])
+async def get_intent_org(pk: Annotated[int, Path(...)]) -> ResponseModel:
+    intent_org = await intent_org_service.get(pk=pk)
+    return response_base.success(data=intent_org)
+
+
+@router.get(
+    '',
+    summary='(模糊条件)分页获取所有 intent_org',
+    dependencies=[
+        DependsJwtAuth,
+        DependsPagination,
+    ],
+)
+async def get_pagination_intent_org(db: CurrentSession) -> ResponseModel:
+    intent_org_select = await intent_org_service.get_select()
+    page_data = await paging_data(db, intent_org_select, GetIntentOrgListDetails)
+    return response_base.success(data=page_data)
+
+
+@router.post(
+    '',
+    summary='创建 intent_org',
+    dependencies=[
+        Depends(RequestPermission('intent_org:add')),
+    ],
+)
+async def create_intent_org(obj: CreateIntentOrgParam) -> ResponseModel:
+    await intent_org_service.create(obj=obj)
+    return response_base.success()
+
+
+@router.put(
+    '/{pk}',
+    summary='更新 intent_org',
+    dependencies=[
+        Depends(RequestPermission('intent_org:edit')),
+    ],
+)
+async def update_intent_org(pk: Annotated[int, Path(...)], obj: UpdateIntentOrgParam) -> ResponseModel:
+    count = await intent_org_service.update(pk=pk, obj=obj)
+    if count > 0:
+        return response_base.success()
+    return response_base.fail()
+
+
+@router.delete(
+    '',
+    summary='(批量)删除 intent_org',
+    dependencies=[
+        Depends(RequestPermission('intent_org:del')),
+    ],
+)
+async def delete_intent_org(pk: Annotated[list[int], Query(...)]) -> ResponseModel:
+    count = await intent_org_service.delete(pk=pk)
+    if count > 0:
+        return response_base.success()
+    return response_base.fail()

+ 0 - 0
app/admin/crud/__init__.py


+ 82 - 0
app/admin/crud/crud_intent_org.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from typing import Sequence
+
+from model.intent_org import IntentOrg
+from app.admin.schema.intent_org import CreateIntentOrgParam, UpdateIntentOrgParam
+from sqlalchemy import Select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy_crud_plus import CRUDPlus
+
+
+class CRUDIntentOrg(CRUDPlus[IntentOrg]):
+    async def get(self, db: AsyncSession, pk: int) -> IntentOrg | None:
+        """
+        获取IntentOrg
+
+        :param db:
+        :param pk:
+        :return:
+        """
+        return await self.select_model(db, pk)
+
+    async def get_by_token(self, db: AsyncSession, token: str) -> IntentOrg | None:
+        """
+        获取IntentOrg
+
+        :param db:
+        :param token:
+        :return:
+        """
+        return await self.select_model_by_column(db, api_key=token)
+
+    async def get_list(self) -> Select:
+        """
+        获取IntentOrg列表
+
+        :return:
+        """
+        return await self.select_order('created_at', 'desc')
+
+    async def get_all(self, db: AsyncSession) -> Sequence[IntentOrg]:
+        """
+        获取所有IntentOrg
+
+        :param db:
+        :return:
+        """
+        return await self.select_models(db)
+
+    async def create(self, db: AsyncSession, obj_in: CreateIntentOrgParam) -> None:
+        """
+        创建IntentOrg
+
+        :param db:
+        :param obj_in:
+        :return:
+        """
+        await self.create_model(db, obj_in)
+
+    async def update(self, db: AsyncSession, pk: int, obj_in: UpdateIntentOrgParam) -> int:
+        """
+        更新IntentOrg
+
+        :param db:
+        :param pk:
+        :param obj_in:
+        :return:
+        """
+        return await self.update_model(db, pk, obj_in)
+
+    async def delete(self, db: AsyncSession, pk: list[int]) -> int:
+        """
+        删除IntentOrg
+
+        :param db:
+        :param pk:
+        :return:
+        """
+        return  await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
+
+
+intent_org_dao: CRUDIntentOrg = CRUDIntentOrg(IntentOrg)

+ 0 - 0
app/admin/schema/__init__.py


+ 10 - 0
app/admin/schema/auth.py

@@ -0,0 +1,10 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from common.schema import SchemaBase
+
+
+class AuthSchemaBase(SchemaBase):
+    id: int
+
+class AuthGenTokenParam(AuthSchemaBase):
+    pass

+ 33 - 0
app/admin/schema/intent_org.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import datetime
+
+from pydantic import ConfigDict
+
+from common.schema import SchemaBase
+
+
+class IntentOrgSchemaBase(SchemaBase):
+    name: str
+    api_key: str
+    openai_base: str
+    openai_key: str
+    intent_callback: str
+    status: int
+    deleted_time: datetime | None = None
+
+
+class CreateIntentOrgParam(IntentOrgSchemaBase):
+    pass
+
+
+class UpdateIntentOrgParam(IntentOrgSchemaBase):
+    pass
+
+
+class GetIntentOrgListDetails(IntentOrgSchemaBase):
+    model_config = ConfigDict(from_attributes=True)
+
+class CurrentIntentOrgIns(GetIntentOrgListDetails):
+    id: int
+    model_config = ConfigDict(from_attributes=True)

+ 24 - 0
app/admin/schema/token.py

@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import datetime
+
+from common.schema import SchemaBase
+
+
+class GetSwaggerToken(SchemaBase):
+    access_token: str
+    token_type: str = 'Bearer'
+
+
+class AccessTokenBase(SchemaBase):
+    access_token: str
+    access_token_type: str = 'Bearer'
+    access_token_expire_time: datetime
+
+
+class GetNewToken(AccessTokenBase):
+    pass
+
+
+class GetLoginToken(AccessTokenBase):
+    pass

+ 0 - 0
app/admin/service/__init__.py


+ 22 - 0
app/admin/service/auth_service.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from app.admin.schema.token import GetLoginToken
+from app.admin.schema.auth import AuthGenTokenParam
+from common.security.jwt_call_center import create_access_token
+
+
+class AuthService:
+    async def gen_token(
+        self, *, obj: AuthGenTokenParam
+    ) -> GetLoginToken:
+        user_id = obj.id
+        a_token = await create_access_token(str(user_id))
+
+        data = GetLoginToken(
+            access_token=a_token.access_token,
+            access_token_expire_time=a_token.access_token_expire_time,
+        )
+        return data
+
+
+auth_service: AuthService = AuthService()

+ 50 - 0
app/admin/service/intent_org_service.py

@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from typing import Sequence
+
+from app.admin.crud.crud_intent_org import intent_org_dao
+from model.intent_org import IntentOrg
+from app.admin.schema.intent_org import CreateIntentOrgParam, UpdateIntentOrgParam
+from common.exception import errors
+from database.db_mysql import async_db_session
+from sqlalchemy import Select
+
+
+class IntentOrgService:
+    @staticmethod
+    async def get(*, pk: int) -> IntentOrg:
+        async with async_db_session() as db:
+            intent_org = await intent_org_dao.get(db, pk)
+            if not intent_org:
+                raise errors.NotFoundError(msg='intent_org 不存在')
+            return intent_org
+
+    @staticmethod
+    async def get_select() -> Select:
+        return await intent_org_dao.get_list()
+
+    @staticmethod
+    async def get_all() -> Sequence[IntentOrg]:
+        async with async_db_session() as db:
+            intent_org = await intent_org_dao.get_all(db)
+            return intent_org
+
+    @staticmethod
+    async def create(*, obj: CreateIntentOrgParam) -> None:
+        async with async_db_session.begin() as db:
+            await intent_org_dao.create(db, obj)
+
+    @staticmethod
+    async def update(*, pk: int, obj: UpdateIntentOrgParam) -> int:
+        async with async_db_session.begin() as db:
+            count = await intent_org_dao.update(db, pk, obj)
+            return count
+
+    @staticmethod
+    async def delete(*, pk: list[int]) -> int:
+        async with async_db_session.begin() as db:
+            count = await intent_org_dao.delete(db, pk)
+            return count
+
+
+intent_org_service: IntentOrgService = IntentOrgService()

+ 0 - 0
app/call_center/__init__.py


+ 0 - 0
app/call_center/api/__init__.py


+ 7 - 0
app/call_center/api/intent/__init__.py

@@ -0,0 +1,7 @@
+from fastapi import APIRouter
+
+from app.call_center.api.intent.evaluate import router
+
+v1 = APIRouter(prefix='/intent', tags=['意向度'])
+
+v1.include_router(router, prefix='/evaluate', tags=['评级'])

+ 96 - 0
app/call_center/api/intent/evaluate.py

@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import uuid
+from typing import Annotated
+
+from app.call_center.schema.intent_records import CreateIntentRecordsParam, GetIntentRecordsListDetails, \
+    UpdateIntentRecordsParam, GetIntentRecordsByIdParam, intent_status_map
+from app.call_center.service.intent_records_service import intent_records_service
+from common.pagination import paging_data
+from common.response.response_schema import ResponseModel, response_base
+from common.security.jwt_call_center import DependsJwtAuth
+from common.security.permission import RequestPermission
+from database.db_mysql import CurrentSession
+from fastapi import APIRouter, Depends, Query, Request
+
+router = APIRouter()
+
+@router.get(
+    '/result',
+    summary='获取 record',
+    dependencies=[
+        DependsJwtAuth,
+    ],
+)
+async def get_intent_record(obj: GetIntentRecordsByIdParam, request: Request) -> ResponseModel:
+    obj.org_id = request.user.id
+    intent_record_select = await intent_records_service.get_select_by_id(obj=obj)
+    data = None
+    if intent_record_select:
+        intent = None
+        if intent_record_select.llm_intent:
+            intent = intent_status_map.get(intent_record_select.llm_intent, "其他")
+        data = {
+            "status": intent_record_select.status,
+            "internal_id": intent_record_select.id,
+            "external_id": intent_record_select.external_id,
+            "score": intent_record_select.llm_intent,
+            "intent": intent
+        }
+    return response_base.success(data=data)
+
+@router.get(
+    '',
+    summary='(模糊条件)分页获取所有record',
+    dependencies=[
+        DependsJwtAuth,
+    ],
+)
+async def get_pagination_intent_record(db: CurrentSession) -> ResponseModel:
+    intent_record_select = await intent_records_service.get_select()
+    page_data = await paging_data(db, intent_record_select, GetIntentRecordsListDetails)
+    return response_base.success(data=page_data)
+
+
+@router.post(
+    '',
+    summary='创建record',
+    dependencies=[
+        DependsJwtAuth
+    ],
+)
+async def create_intent_record(obj: CreateIntentRecordsParam, request: Request) -> ResponseModel:
+    id_str = str(uuid.uuid4())
+    obj.id = id_str
+    obj.org_id = request.user.id
+    await intent_records_service.create(obj=obj)
+    return response_base.success()
+
+
+@router.post(
+    '/update',
+    summary='更新record',
+    dependencies=[
+        DependsJwtAuth
+    ],
+)
+async def update_intent_record(obj: UpdateIntentRecordsParam, request: Request) -> ResponseModel:
+    obj.org_id = request.user.id
+    count = await intent_records_service.update_manual_intent(obj=obj)
+    if count > 0:
+        return response_base.success()
+    return response_base.fail()
+
+
+@router.delete(
+    '',
+    summary='(批量)删除record',
+    dependencies=[
+        Depends(RequestPermission('call:record:del')),
+    ],
+)
+async def delete_intent_record(pk: Annotated[list[int], Query(...)]) -> ResponseModel:
+    count = await intent_records_service.delete(pk=pk)
+    if count > 0:
+        return response_base.success()
+    return response_base.fail()

+ 0 - 0
app/call_center/crud/__init__.py


+ 120 - 0
app/call_center/crud/crud_intent_records.py

@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from typing import Sequence
+
+from model.intent_records import IntentRecords
+from app.call_center.schema.intent_records import CreateIntentRecordsParam, UpdateIntentRecordsParam, \
+    GetIntentRecordsByIdParam
+from sqlalchemy import Select, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy_crud_plus import CRUDPlus
+
+
+class CRUDIntentRecords(CRUDPlus[IntentRecords]):
+    async def get(self, db: AsyncSession, obj_in: GetIntentRecordsByIdParam) -> IntentRecords | None:
+        """
+        获取call record
+
+        :param db:
+        :param obj_in:
+        :return:
+        """
+        intent_records = None
+        if obj_in.internal_id:
+            intent_records = await self.select_model_by_column(db, id=obj_in.internal_id, org_id=obj_in.org_id)
+        elif obj_in.external_id:
+            intent_records = await self.select_model_by_column(db, external_id=obj_in.external_id, org_id=obj_in.org_id)
+        return intent_records
+
+    async def get_earliest_record(self, db: AsyncSession) -> IntentRecords | None:
+        """
+        获取最早的 llm_intent 不为空的记录
+
+        :param db:
+        :return:
+        """
+        stmt = select(self.model).where(IntentRecords.llm_intent == None).order_by(IntentRecords.created_at.asc())
+        query = await db.execute(stmt)
+        return query.scalars().first()
+
+    async def get_list(self) -> Select:
+        """
+        获取call record列表
+
+        :return:
+        """
+        return await self.select_order('created_at', 'desc')
+
+    async def get_all(self, db: AsyncSession) -> Sequence[IntentRecords]:
+        """
+        获取所有call record
+
+        :param db:
+        :return:
+        """
+        return await self.select_models(db)
+
+    async def create(self, db: AsyncSession, obj_in: CreateIntentRecordsParam) -> None:
+        """
+        创建call record
+
+        :param db:
+        :param obj_in:
+        :return:
+        """
+        await self.create_model(db, obj_in)
+
+    async def update(self, db: AsyncSession, pk: int, obj_in: UpdateIntentRecordsParam) -> int:
+        """
+        更新call record
+
+        :param db:
+        :param pk:
+        :param obj_in:
+        :return:
+        """
+        return await self.update_model(db, pk, obj_in)
+
+    async def update_manual_intent(self, db: AsyncSession, obj_in: UpdateIntentRecordsParam) -> int:
+        """
+        更新call record
+
+        :param db:
+        :param pk:
+        :param obj_in:
+        :return:
+        """
+        if obj_in.internal_id:
+            return await self.update_model_by_column(db, {'manual_intent': obj_in.manual_intent}, id=obj_in.internal_id)
+        else:
+            return await self.update_model_by_column(db, {'manual_intent': obj_in.manual_intent}, external_id=obj_in.external_id)
+
+    async def update_llm_intent(self, db: AsyncSession, internal_id: str, llm_intent: int, request_data: dict, response_data: dict, status: int) -> int:
+        """
+        更新call record
+
+        :param db:
+        :param pk:
+        :param internal_id:
+        :param llm_intent:
+        :param request_data:
+        :param response_data:
+        :param status:
+        :return:
+        """
+
+        return await self.update_model_by_column(db, {'llm_intent': llm_intent, 'request_data': request_data, 'response_data': response_data, 'status': status}, id=internal_id)
+
+
+    async def delete(self, db: AsyncSession, pk: list[int]) -> int:
+        """
+        删除call record
+
+        :param db:
+        :param pk:
+        :return:
+        """
+        return  await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
+
+
+intent_records_dao: CRUDIntentRecords = CRUDIntentRecords(IntentRecords)

+ 0 - 0
app/call_center/schema/__init__.py


+ 55 - 0
app/call_center/schema/intent_records.py

@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import datetime
+
+from pydantic import ConfigDict
+
+from common.schema import SchemaBase
+
+intent_status_map = {
+    1: "有意向",
+    2: "无意向",
+    3: "其他"
+}
+
+
+class IntentRecordsSchemaBase(SchemaBase):
+    pass
+
+class GetIntentRecordsByIdParam(IntentRecordsSchemaBase):
+    internal_id: str | None = None
+    external_id: str | None = None
+    org_id: int | None = 0
+
+class CreateIntentRecordsParam(IntentRecordsSchemaBase):
+    id: str | None = 0
+    external_id: str
+    industry_type: int | None = 0
+    chat_history: str
+    manual_intent: int | None = None
+    org_id: int | None = 0
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class UpdateIntentRecordsParam(IntentRecordsSchemaBase):
+    internal_id: str | None = None
+    external_id: str | None = None
+    manual_intent: int
+    org_id: int | None = 0
+
+
+class GetIntentRecordsListDetails(IntentRecordsSchemaBase):
+    model_config = ConfigDict(from_attributes=True)
+
+    id: str
+    created_at: datetime
+    updated_at: datetime | None = None
+
+class GetIntentRecordsDetails(IntentRecordsSchemaBase):
+    model_config = ConfigDict(from_attributes=True)
+
+    id: str
+    external_id: str
+    chat_history: str
+    org_id: int | None = 0

+ 0 - 0
app/call_center/service/__init__.py


+ 62 - 0
app/call_center/service/intent_records_service.py

@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from typing import Sequence
+
+from app.call_center.crud.crud_intent_records import intent_records_dao
+from model.intent_records import IntentRecords
+from app.call_center.schema.intent_records import CreateIntentRecordsParam, UpdateIntentRecordsParam, \
+    GetIntentRecordsByIdParam
+from common.exception import errors
+from database.db_mysql import async_db_session
+from sqlalchemy import Select
+
+
+class IntentRecordsService:
+    @staticmethod
+    async def get(*, pk: str) -> IntentRecords:
+        async with async_db_session() as db:
+            intent_records = await intent_records_dao.get(db, pk)
+            if not intent_records:
+                raise errors.NotFoundError(msg='record不存在')
+            return intent_records
+
+    @staticmethod
+    async def get_select_by_id(*, obj: GetIntentRecordsByIdParam) -> IntentRecords | None:
+        async with async_db_session.begin() as db:
+            return await intent_records_dao.get(db, obj)
+
+    @staticmethod
+    async def get_select() -> Select:
+        return await intent_records_dao.get_list()
+
+    @staticmethod
+    async def get_all() -> Sequence[IntentRecords]:
+        async with async_db_session() as db:
+            intent_records = await intent_records_dao.get_all(db)
+            return intent_records
+
+    @staticmethod
+    async def create(*, obj: CreateIntentRecordsParam) -> None:
+        async with async_db_session.begin() as db:
+            await intent_records_dao.create(db, obj)
+
+    @staticmethod
+    async def update(*, pk: int, obj: UpdateIntentRecordsParam) -> int:
+        async with async_db_session.begin() as db:
+            count = await intent_records_dao.update(db, pk, obj)
+            return count
+
+    @staticmethod
+    async def update_manual_intent(*, obj: UpdateIntentRecordsParam) -> int:
+        async with async_db_session.begin() as db:
+            count = await intent_records_dao.update_manual_intent(db, obj)
+            return count
+
+    @staticmethod
+    async def delete(*, pk: list[int]) -> int:
+        async with async_db_session.begin() as db:
+            count = await intent_records_dao.delete(db, pk)
+            return count
+
+
+intent_records_service: IntentRecordsService = IntentRecordsService()

+ 5 - 6
app/gpt/api/route.py

@@ -1,14 +1,13 @@
 import json
 import json
 import os
 import os
 import tempfile
 import tempfile
-from sre_constants import error
 from fastapi import Request
 from fastapi import Request
 
 
 import requests
 import requests
 from fastapi import APIRouter
 from fastapi import APIRouter
 from pydantic import BaseModel
 from pydantic import BaseModel
 from sqlalchemy import text
 from sqlalchemy import text
-from starlette.responses import FileResponse, StreamingResponse, HTMLResponse
+from starlette.responses import FileResponse, HTMLResponse
 
 
 from common.log import log
 from common.log import log
 
 
@@ -28,7 +27,7 @@ class Response(BaseModel):
     result: Result = Result(error=0, msg="")
     result: Result = Result(error=0, msg="")
 
 
 
 
-@router.get("/gpt/recordlist", response_class=HTMLResponse)
+@router.get("/recordlist", response_class=HTMLResponse)
 async def record_list(request: Request):
 async def record_list(request: Request):
     async with async_db_session() as db:
     async with async_db_session() as db:
         query = text("select * from records order by id desc")
         query = text("select * from records order by id desc")
@@ -39,7 +38,7 @@ async def record_list(request: Request):
     )
     )
 
 
 
 
-@router.get("/gpt/recordfile", summary="录音文件转发")
+@router.get("/recordfile", summary="录音文件转发")
 async def record_file(filepath: str):
 async def record_file(filepath: str):
     url = "http://xycc.ascrm.cn/recordFile/download?file=" + filepath
     url = "http://xycc.ascrm.cn/recordFile/download?file=" + filepath
 
 
@@ -61,14 +60,14 @@ async def record_file(filepath: str):
     return FileResponse(file_path, media_type="application/octet-stream", filename=file_name)
     return FileResponse(file_path, media_type="application/octet-stream", filename=file_name)
 
 
 
 
-@router.post("/gpt/ali_trans_callback", summary="阿里云录音文件识别回调地址")
+@router.post("/ali_trans_callback", summary="阿里云录音文件识别回调地址")
 async def ali_trans_callback(body: dict):
 async def ali_trans_callback(body: dict):
     log.info(json.dumps(body, indent=4))
     log.info(json.dumps(body, indent=4))
     await ali_trans_success(body)
     await ali_trans_success(body)
     return Response()
     return Response()
 
 
 
 
-@router.post("/gpt/intent", summary="用户意图判断")
+@router.post("/intent", summary="用户意图判断")
 async def user_intent(body: dict):
 async def user_intent(body: dict):
     log.info(json.dumps(body, indent=4))
     log.info(json.dumps(body, indent=4))
 
 

+ 0 - 1
app/gpt/service/ali_filetrans.py

@@ -1,6 +1,5 @@
 import json
 import json
 import os
 import os
-import time
 from aliyunsdkcore.acs_exception.exceptions import ClientException
 from aliyunsdkcore.acs_exception.exceptions import ClientException
 from aliyunsdkcore.acs_exception.exceptions import ServerException
 from aliyunsdkcore.acs_exception.exceptions import ServerException
 from aliyunsdkcore.client import AcsClient
 from aliyunsdkcore.client import AcsClient

+ 8 - 3
app/router.py

@@ -3,8 +3,13 @@
 from fastapi import APIRouter
 from fastapi import APIRouter
 
 
 from app.gpt.api import v1 as gpt_v1
 from app.gpt.api import v1 as gpt_v1
-from core.conf import settings
+from app.admin.api import v1 as admin_v1
+from app.call_center.api.intent import v1 as intent_v1
 
 
-route = APIRouter(prefix=settings.API_V1_STR)
+route = APIRouter()
 
 
-route.include_router(gpt_v1)
+route.include_router(gpt_v1)
+route.include_router(admin_v1)
+
+call_center_route = APIRouter()
+call_center_route.include_router(intent_v1)

+ 0 - 0
batch_task/__init__.py


+ 35 - 0
batch_task/batch_task.py

@@ -0,0 +1,35 @@
+import asyncio
+import threading
+from concurrent.futures import ThreadPoolExecutor
+
+from batch_task.update_llm_intent import update_llm_intent
+from common.log import log
+
+batch_task_event = threading.Event()
+
+async def periodically_execute(interval, func, *args, **kwargs):
+    while True:
+        await func(*args, **kwargs)
+        await asyncio.sleep(interval)
+
+async def execute_task():
+    batch_task_event.set()
+    while batch_task_event.is_set():
+        await update_llm_intent()
+        log.info("Task executed. Sleeping for 10 seconds...")
+        await asyncio.sleep(10)
+
+def start_batch_task():
+    global batch_task_event
+    if not batch_task_event.is_set():
+        log.info("Starting task.")
+        batch_task_event.set()
+        asyncio.run(execute_task())
+        # asyncio.create_task(execute_task())
+
+
+def stop_batch_task():
+    global batch_task_event
+    # 清除事件
+    batch_task_event.clear()
+    log.info("Stop task.")

+ 189 - 0
batch_task/update_llm_intent.py

@@ -0,0 +1,189 @@
+import json
+from time import sleep
+from typing import Dict
+
+import requests
+from openai import OpenAI
+import asyncio
+import aiohttp
+from app.admin.schema.intent_org import CurrentIntentOrgIns
+from app.call_center.crud.crud_intent_records import intent_records_dao
+from app.call_center.schema.intent_records import GetIntentRecordsDetails
+from common.log import log
+from core.conf import settings
+from database.db_mysql import async_db_session
+from database.db_redis import redis_client
+from utils.serializers import select_as_dict
+
+
+async def update_llm_intent():
+    async with async_db_session.begin() as db:
+        record = await intent_records_dao.get_earliest_record(db)
+        if not record:
+            return None
+        record_data = GetIntentRecordsDetails(**select_as_dict(record))
+
+        # 机构 id
+        org_id = record_data.org_id
+
+        # 从缓存中获取机构信息
+        key = f'{settings.TOKEN_CALL_REDIS_PREFIX}:{org_id}'
+        org_json = await redis_client.get(key)
+        if not org_json:
+            # 缓存中没有,从数据库中获取
+            from app.admin.crud.crud_intent_org import intent_org_dao
+            org = await intent_org_dao.get(db, org_id)
+            if not org and org.status is not 1:
+                log.error(f"意向评级时,机构不存在 org_id: {org_id}")
+                return None
+            org_data = CurrentIntentOrgIns(**select_as_dict(org))
+            # 将数据放进缓存
+            await redis_client.setex(
+                key,
+                settings.JWT_USER_REDIS_EXPIRE_SECONDS,
+                org_data.model_dump_json(),
+            )
+        else:
+            org_data = CurrentIntentOrgIns(**json.loads(org_json))
+
+        # 开始评级
+        intent_schema = {
+            "name": "intent_schema",
+            "schema": {  # 添加 schema 字段
+                "type": "object",
+                "description": "为通话记录进行评级",
+                "properties": {
+                    "score": {"type": "string", "description": "评分结果:有意向、待进一步分析、暂时无法沟通、其他"},
+                    "confidence_score": {"type": "number", "description": "置信度分数,范围从0.0到1.0"},
+                    "scoring_criteria": {"type": "string", "description": "请逐步介绍为何评为这个结果"}
+                },
+                "required": ["score", "confidence_score", "scoring_criteria"]
+            }
+        }
+
+        rules = [
+            {
+                'rule': "用户表现出对产品有积极回应,询问有关产品的问题,或愿意进行后续操作,如确认微信号、询问发货地址或表达接受产品(例:'行'、'好'、'可以'、'是的')。",
+                'score': '有意向'},
+            {
+                'rule': "用户虽然未积极回应或表示不需要,但却有相关需求、或着符合某项购买条件、又或着询问了机器人问题,即便后期疲软或持否定态度,可移交销售顾问进行进一步的需求分析,以便改善产品或定制符合客户要求的产品",
+                'score': '待进一步分析'},
+            {
+                'rule': "用户表示自己在忙、在开会等,现在不太方便沟通。又或者对电话的真实性存疑,怀疑是诈骗电话,可后续让专业客服继续跟进",
+                'score': '暂时无法沟通'},
+            {
+                'rule': "除上述规则外的其他用户,请注意客户资源珍贵,请尽量归类为'有意向'或'待进一步分析',后续再由销售专家定性。",
+                'score': '其他'}
+        ]
+
+        score_map = {
+            '有意向': 1,
+            '待进一步分析': 2,
+            '暂时无法沟通': 3,
+            '其他': 4
+        }
+
+        messages = [
+            {"role": "system", "content": f"""# 任务
+1. 首先,判断用户的第一句话是否说了:“你好,(任意内容)通话”,如果说了,则不用理会评级规则,直接强制分配为"语音助手"
+2. 如果不属于“语音助手”,请根据评级规则,对聊天记录给出评级、置信度、评分依据(逐项分析不要遗漏)
+
+# 细节说明
+置信度从0到1,0为置信度最低,1为置信度最高。"""
+             },
+            {
+                "role": "user",
+                "content": f"""# 评级规则:
+            {rules}
+
+            # 聊天记录
+            {record_data.chat_history}
+            """
+            }
+        ]
+        response_data = generate_json(org_data.api_key, org_data.openai_base, messages, intent_schema)
+        if response_data and isinstance(response_data.choices, list) and len(response_data.choices) > 0:
+            first_choice = response_data.choices[0]
+            if first_choice and first_choice.message:
+                response_json = first_choice.message.content
+                if response_json:
+                    intent = json.loads(response_json)
+
+                    score = intent.get('score', "未知")
+                    llm_intent = score_map.get(score, 0)
+                    # confidence_score = intent.get('confidence_score', 0)
+                    # scoring_criteria = intent.get('scoring_criteria', "未知")
+                    log.info(f"response_data.to_dict(): {response_data.to_dict()}")
+
+                    status = 2
+
+                    # 推送
+                    url = org_data.intent_callback
+                    if url:
+                        headers = {
+                            "Content-Type": "application/json"
+                        }
+                        data = {
+                            "internal_id": record_data.id,
+                            "external_id": record_data.external_id,
+                            "score": llm_intent,
+                            "intent": score
+                        }
+
+                    is_success = await send_request_with_retry(url, data, headers, max_retries=3, delay_between_retries=2)
+                    if is_success:
+                        status = 3
+
+                    # 存储结果
+                    status = 2
+                    async with async_db_session.begin() as db:
+                        try:
+                            await intent_records_dao.update_llm_intent(db, record_data.id, llm_intent,
+                                                                       {"messages": messages},
+                                                                       response_data.to_dict(),
+                                                                       status)
+                        except Exception as e:
+                            log.error(f"更新意图记录时发生异常:{e}")
+
+
+
+def generate_json(api_key: str, openai_base: str, messages: list[dict], json_schema: dict):
+    try:
+        client_args = {}
+        if api_key:
+            client_args["api_key"] = api_key
+        if openai_base:
+            client_args["base_url"] = openai_base
+
+        oai_client = OpenAI(**client_args)
+
+        completion = oai_client.chat.completions.create(
+            model="gpt-4o",
+            messages=messages,
+            response_format={
+                "type": "json_schema",
+                "json_schema": json_schema
+            }
+        )
+        if completion and isinstance(completion.choices, list) and len(completion.choices) > 0:
+            first_choice = completion.choices[0]
+            if first_choice and first_choice.message:
+                # return first_choice.message.content
+                return completion
+    except Exception as e:
+        log.error(f"[oai] generate_json failed: {e}")
+
+async def send_request_with_retry(url: str, data: Dict, headers: Dict[str, str], max_retries: int, delay_between_retries: int) -> bool:
+    for attempt in range(max_retries):
+        try:
+            async with session.ClientSession() as session:
+                async with session.post(url, json=data, headers=headers, timeout=10) as response:
+                    if response.status == 200:
+                        return True
+        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
+            log.error(f"请求异常:{e}")
+
+        if attempt < max_retries - 1:
+            print("重试中...")
+            await asyncio.sleep(delay_between_retries)
+    return False

+ 18 - 0
common/dataclasses.py

@@ -40,3 +40,21 @@ class NewTokenReturn:
     new_refresh_token: str
     new_refresh_token: str
     new_access_token_expire_time: datetime
     new_access_token_expire_time: datetime
     new_refresh_token_expire_time: datetime
     new_refresh_token_expire_time: datetime
+
+@dataclasses.dataclass
+class NewToken:
+    new_access_token: str
+    new_access_token_expire_time: datetime
+    new_refresh_token: str
+    new_refresh_token_expire_time: datetime
+
+@dataclasses.dataclass
+class AccessToken:
+    access_token: str
+    access_token_expire_time: datetime
+
+
+@dataclasses.dataclass
+class RefreshToken:
+    refresh_token: str
+    refresh_token_expire_time: datetime

+ 6 - 2
common/model.py

@@ -14,6 +14,10 @@ id_key = Annotated[
     int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键id')
     int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键id')
 ]
 ]
 
 
+id_key_str = Annotated[
+    str, mapped_column(primary_key=True, index=True, sort_order=-999, comment='主键id')
+]
+
 
 
 # Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
 # Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
 class UserMixin(MappedAsDataclass):
 class UserMixin(MappedAsDataclass):
@@ -26,10 +30,10 @@ class UserMixin(MappedAsDataclass):
 class DateTimeMixin(MappedAsDataclass):
 class DateTimeMixin(MappedAsDataclass):
     """日期时间 Mixin 数据类"""
     """日期时间 Mixin 数据类"""
 
 
-    created_time: Mapped[datetime] = mapped_column(
+    created_at: Mapped[datetime] = mapped_column(
         init=False, default_factory=timezone.now, sort_order=999, comment='创建时间'
         init=False, default_factory=timezone.now, sort_order=999, comment='创建时间'
     )
     )
-    updated_time: Mapped[datetime | None] = mapped_column(
+    updated_at: Mapped[datetime | None] = mapped_column(
         init=False, onupdate=timezone.now, sort_order=999, comment='更新时间'
         init=False, onupdate=timezone.now, sort_order=999, comment='更新时间'
     )
     )
 
 

+ 116 - 0
common/security/jwt_call_center.py

@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import timedelta
+
+from app.admin.schema.intent_org import CurrentIntentOrgIns
+from fastapi import Depends, Request
+from fastapi.security import HTTPBearer
+from fastapi.security.utils import get_authorization_scheme_param
+from jose import ExpiredSignatureError, JWTError, jwt
+from pydantic_core import from_json
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.log import log
+from model.intent_org import IntentOrg
+from common.dataclasses import AccessToken, NewToken, RefreshToken
+from common.exception.errors import AuthorizationError, TokenError
+from core.conf import settings
+from database.db_mysql import async_db_session
+from database.db_redis import redis_client
+from utils.serializers import select_as_dict
+from utils.timezone import timezone
+
+# JWT authorizes dependency injection
+DependsJwtAuth = Depends(HTTPBearer())
+
+
+async def create_access_token(sub: str) -> AccessToken:
+    """
+    Generate encryption token
+
+    :param sub: The subject/userid of the JWT
+    :param multi_login: multipoint login for user
+    :return:
+    """
+    expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS)
+
+    to_encode = {'exp': expire, 'sub': sub}
+    access_token = jwt.encode(to_encode, settings.TOKEN_SECRET_KEY, settings.TOKEN_ALGORITHM)
+
+    return AccessToken(access_token=access_token, access_token_expire_time=expire)
+
+
+def get_token(request: Request) -> str:
+    """
+    Get token for request header
+
+    :return:
+    """
+    authorization = request.headers.get('Authorization')
+    scheme, token = get_authorization_scheme_param(authorization)
+    if not authorization or scheme.lower() != 'bearer':
+        raise TokenError(msg='Token 无效')
+    return token
+
+
+def jwt_decode(token: str) -> int:
+    """
+    Decode token
+
+    :param token:
+    :return:
+    """
+    try:
+        payload = jwt.decode(token, settings.TOKEN_SECRET_KEY, algorithms=[settings.TOKEN_ALGORITHM])
+        user_id = int(payload.get('sub'))
+        if not user_id:
+            raise TokenError(msg='Token 无效')
+    except ExpiredSignatureError:
+        raise TokenError(msg='Token 已过期')
+    except (JWTError, Exception):
+        raise TokenError(msg='Token 无效')
+    return user_id
+
+
+async def get_current_organization(db: AsyncSession, pk: str) -> IntentOrg:
+    """
+    Get the current user through token
+
+    :param db:
+    :param pk:
+    :return:
+    """
+    from app.admin.crud.crud_intent_org import intent_org_dao
+    org = await intent_org_dao.get_by_token(db, pk)
+    if not org:
+        raise TokenError(msg='Token 无效')
+    if org.status is not 1:
+        raise AuthorizationError(msg='用户已被锁定,请联系系统管理员')
+
+    return org
+
+
+async def jwt_call_center_authentication(token: str) -> CurrentIntentOrgIns:
+    """
+    JWT authentication
+
+    :param token:
+    :return:
+    """
+    org_id = jwt_decode(token)
+    key = f'{settings.TOKEN_CALL_REDIS_PREFIX}:{org_id}'
+    token_verify = await redis_client.get(key)
+    if not token_verify:
+        async with async_db_session() as db:
+            current_org = await get_current_organization(db, token)
+            org = CurrentIntentOrgIns(**select_as_dict(current_org))
+            await redis_client.setex(
+                key,
+                settings.JWT_USER_REDIS_EXPIRE_SECONDS,
+                org.model_dump_json(),
+            )
+    else:
+        # TODO: 在恰当的时机,应替换为使用 model_validate_json
+        # https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing
+        org = CurrentIntentOrgIns.model_validate(from_json(token_verify, allow_partial=True))
+    return org

+ 3 - 0
core/conf.py

@@ -87,11 +87,14 @@ class Settings(BaseSettings):
     TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1  # 过期时间,单位:秒
     TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1  # 过期时间,单位:秒
     TOKEN_REFRESH_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7  # 刷新过期时间,单位:秒
     TOKEN_REFRESH_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7  # 刷新过期时间,单位:秒
     TOKEN_REDIS_PREFIX: str = 'fba:token'
     TOKEN_REDIS_PREFIX: str = 'fba:token'
+    TOKEN_CALL_REDIS_PREFIX: str = 'fba:call_token'
     TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:token:refresh'
     TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:token:refresh'
     TOKEN_EXCLUDE: list[str] = [  # JWT / RBAC 白名单
     TOKEN_EXCLUDE: list[str] = [  # JWT / RBAC 白名单
         f'{API_V1_STR}/auth/login',
         f'{API_V1_STR}/auth/login',
     ]
     ]
 
 
+    JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7
+
     # Sys User
     # Sys User
     USER_REDIS_PREFIX: str = 'fba:user'
     USER_REDIS_PREFIX: str = 'fba:user'
     USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7
     USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7

+ 74 - 12
core/registrar.py

@@ -1,19 +1,24 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
+import asyncio
+import threading
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 
 
-from fastapi import Depends, FastAPI
+from fastapi import Depends, FastAPI, APIRouter
 from fastapi_limiter import FastAPILimiter
 from fastapi_limiter import FastAPILimiter
 from fastapi_pagination import add_pagination
 from fastapi_pagination import add_pagination
 from starlette.middleware.authentication import AuthenticationMiddleware
 from starlette.middleware.authentication import AuthenticationMiddleware
 
 
-from app.router import route
+from app.router import route, call_center_route
+from batch_task.batch_task import start_batch_task, stop_batch_task, execute_task, \
+    periodically_execute
+from batch_task.update_llm_intent import update_llm_intent
 from common.exception.exception_handler import register_exception
 from common.exception.exception_handler import register_exception
-from common.log import set_customize_logfile, setup_logging
+from common.log import set_customize_logfile, setup_logging, log
 from core.conf import settings
 from core.conf import settings
 from core.path_conf import STATIC_DIR
 from core.path_conf import STATIC_DIR
-from database.db_mysql import create_table
 from database.db_redis import redis_client
 from database.db_redis import redis_client
+from middleware.jwt_call_center_auth_middleware import JwtCallCenterAuthMiddleware
 
 
 from middleware.opera_log_middleware import OperaLogMiddleware
 from middleware.opera_log_middleware import OperaLogMiddleware
 from utils.demo_site import demo_site
 from utils.demo_site import demo_site
@@ -21,7 +26,6 @@ from utils.health_check import ensure_unique_route_names, http_limit_callback
 from utils.openapi import simplify_operation_ids
 from utils.openapi import simplify_operation_ids
 from utils.serializers import MsgSpecJSONResponse
 from utils.serializers import MsgSpecJSONResponse
 
 
-
 @asynccontextmanager
 @asynccontextmanager
 async def register_init(app: FastAPI):
 async def register_init(app: FastAPI):
     """
     """
@@ -35,7 +39,6 @@ async def register_init(app: FastAPI):
     await redis_client.open()
     await redis_client.open()
     # 初始化 limiter
     # 初始化 limiter
     await FastAPILimiter.init(redis_client, prefix=settings.LIMITER_REDIS_PREFIX, http_callback=http_limit_callback)
     await FastAPILimiter.init(redis_client, prefix=settings.LIMITER_REDIS_PREFIX, http_callback=http_limit_callback)
-
     yield
     yield
 
 
     # 关闭 redis 连接
     # 关闭 redis 连接
@@ -57,23 +60,52 @@ def register_app():
         lifespan=register_init,
         lifespan=register_init,
     )
     )
 
 
+    admin_app = FastAPI(
+        title=settings.TITLE,
+        version=settings.VERSION,
+        description=settings.DESCRIPTION,
+        default_response_class=MsgSpecJSONResponse,
+    )
+
+    call_center_app = FastAPI(
+        title="Call Center",  # 子应用的标题
+        version="0.1.0",  # 子应用的版本
+        default_response_class=MsgSpecJSONResponse,
+    )
+
+    # 中间件
+    register_call_center_middleware(call_center_app)
+
+    # 路由
+    register_router(call_center_app, call_center_route)
+
+    # 分页
+    register_page(call_center_app)
+
+    # 全局异常处理
+    register_exception(call_center_app)
+
+    app.mount(f"/call_center", call_center_app)
+
     # 日志
     # 日志
     register_logger()
     register_logger()
 
 
     # 静态文件
     # 静态文件
-    register_static_file(app)
+    register_static_file(admin_app)
 
 
     # 中间件
     # 中间件
-    register_middleware(app)
+    register_middleware(admin_app)
 
 
     # 路由
     # 路由
-    register_router(app)
+    register_router(admin_app, route)
 
 
     # 分页
     # 分页
-    register_page(app)
+    register_page(admin_app)
 
 
     # 全局异常处理
     # 全局异常处理
-    register_exception(app)
+    register_exception(admin_app)
+
+    app.mount(f"{settings.API_V1_STR}/gpt", admin_app)
 
 
     return app
     return app
 
 
@@ -135,12 +167,42 @@ def register_middleware(app: FastAPI):
             allow_headers=['*'],
             allow_headers=['*'],
         )
         )
 
 
+def register_call_center_middleware(app: FastAPI):
+    """
+    中间件,执行顺序从下往上
+
+    :param app:
+    :return:
+    """
+    # Opera log (required)
+    app.add_middleware(OperaLogMiddleware)
+    # JWT auth (required)
+    app.add_middleware(
+        AuthenticationMiddleware, backend=JwtCallCenterAuthMiddleware(), on_error=JwtCallCenterAuthMiddleware.auth_exception_handler
+    )
+    # Access log
+    if settings.MIDDLEWARE_ACCESS:
+        from middleware.access_middleware import AccessMiddleware
+
+        app.add_middleware(AccessMiddleware)
+    # CORS: Always at the end
+    if settings.MIDDLEWARE_CORS:
+        from fastapi.middleware.cors import CORSMiddleware
+
+        app.add_middleware(
+            CORSMiddleware,
+            allow_origins=['*'],
+            allow_credentials=True,
+            allow_methods=['*'],
+            allow_headers=['*'],
+        )
 
 
-def register_router(app: FastAPI):
+def register_router(app: FastAPI, route: APIRouter):
     """
     """
     路由
     路由
 
 
     :param app: FastAPI
     :param app: FastAPI
+    :param route: APIRouter
     :return:
     :return:
     """
     """
     dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None
     dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None

+ 7 - 20
main.py

@@ -1,21 +1,6 @@
-from contextlib import asynccontextmanager
-from pathlib import Path
-
-from fastapi import FastAPI
-import uvicorn
-
-# app = FastAPI()
-
-
-# @app.get("/")
-# async def root():
-#     return {"message": "Hello World"}
-#
-#
-# @app.get("/hello/{name}")
-# async def say_hello(name: str):
-#     return {"message": f"Hello {name}"}
+import atexit
 
 
+from batch_task.batch_task import start_batch_task, stop_batch_task
 
 
 from core.registrar import register_app
 from core.registrar import register_app
 
 
@@ -26,10 +11,12 @@ app = register_app()
 if __name__ == '__main__':
 if __name__ == '__main__':
     # 如果你喜欢在 IDE 中进行 DEBUG,main 启动方法会很有帮助
     # 如果你喜欢在 IDE 中进行 DEBUG,main 启动方法会很有帮助
     # 如果你喜欢通过 print 方式进行调试,建议使用 fastapi cli 方式启动服务
     # 如果你喜欢通过 print 方式进行调试,建议使用 fastapi cli 方式启动服务
+    atexit.register(stop_batch_task)
     try:
     try:
-        config = uvicorn.Config(app=f'{Path(__file__).stem}:app', reload=False)
-        server = uvicorn.Server(config)
-        server.run()
+        start_batch_task()
+        # config = uvicorn.Config(app=f'{Path(__file__).stem}:app', reload=False, workers=15)
+        # server = uvicorn.Server(config)
+        # server.run()
 
 
         # uvicorn.run(app, host='127.0.0.1', port=8000)
         # uvicorn.run(app, host='127.0.0.1', port=8000)
     except Exception as e:
     except Exception as e:

+ 55 - 0
middleware/jwt_call_center_auth_middleware.py

@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from typing import Any
+
+from fastapi import Request, Response
+from fastapi.security.utils import get_authorization_scheme_param
+from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError
+from starlette.requests import HTTPConnection
+
+from app.admin.schema.intent_org import CurrentIntentOrgIns
+from common.exception.errors import TokenError
+from common.log import log
+from common.security.jwt_call_center import jwt_call_center_authentication
+from core.conf import settings
+from utils.serializers import MsgSpecJSONResponse
+
+
+class _AuthenticationError(AuthenticationError):
+    """重写内部认证错误类"""
+
+    def __init__(self, *, code: int = None, msg: str = None, headers: dict[str, Any] | None = None):
+        self.code = code
+        self.msg = msg
+        self.headers = headers
+
+
+class JwtCallCenterAuthMiddleware(AuthenticationBackend):
+    """JWT 认证中间件"""
+
+    @staticmethod
+    def auth_exception_handler(conn: HTTPConnection, exc: _AuthenticationError) -> Response:
+        """覆盖内部认证错误处理"""
+        return MsgSpecJSONResponse(content={'code': exc.code, 'msg': exc.msg, 'data': None}, status_code=exc.code)
+
+    async def authenticate(self, request: Request) -> tuple[AuthCredentials, CurrentIntentOrgIns] | None:
+        token = request.headers.get('Authorization')
+        if not token:
+            return
+
+        if request.url.path in settings.TOKEN_EXCLUDE:
+            return
+        scheme, token = get_authorization_scheme_param(token)
+        if scheme.lower() != "bearer":
+            return
+        try:
+            org = await jwt_call_center_authentication(token)
+        except TokenError as exc:
+            raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers)
+        except Exception as e:
+            log.error(f'JWT 授权异常:{e}')
+            raise _AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error'))
+
+        # 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
+        # 标准返回模式请查看:https://www.starlette.io/authentication/
+        return AuthCredentials(['authenticated']), org

+ 27 - 0
model/intent_org.py

@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import datetime
+
+import sqlalchemy as sa
+from sqlalchemy import BigInteger, TIMESTAMP, text
+
+from common.model import Base, id_key
+from sqlalchemy.dialects import mysql
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class IntentOrg(Base):
+    """organization"""
+
+    __tablename__ = 'intent_org'
+
+    id: Mapped[id_key] = mapped_column(BigInteger, primary_key=True)
+    created_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='Create Time | 创建日期')
+    updated_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='Update Time | 修改日期')
+    name: Mapped[str | None] = mapped_column(sa.String(255), default=None, sort_order=2, comment='机构名称')
+    api_key: Mapped[str | None] = mapped_column(sa.String(255), default=None, sort_order=3, comment='')
+    openai_base: Mapped[str | None] = mapped_column(sa.String(255), default=None, sort_order=4, comment='')
+    openai_key: Mapped[str | None] = mapped_column(sa.String(255), default=None, sort_order=5, comment='')
+    intent_callback: Mapped[str | None] = mapped_column(sa.String(255), default=None, sort_order=6, comment='意向度结果推送地址')
+    status: Mapped[int] = mapped_column(mysql.TINYINT(), default=0, sort_order=9, comment='状态 1 正常 2 禁用')
+    deleted_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, default=None, sort_order=10, comment='Delete Time | 删除日期')

+ 30 - 0
model/intent_records.py

@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import datetime
+from uuid import UUID
+
+import sqlalchemy as sa
+from sqlalchemy import TIMESTAMP, text
+
+from common.model import Base, id_key_str
+from sqlalchemy.dialects import mysql
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class IntentRecords(Base):
+    """call record"""
+
+    __tablename__ = 'intent_records'
+
+    id: Mapped[id_key_str] = mapped_column()
+    created_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'), comment='Create Time | 创建日期')
+    updated_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), comment='Update Time | 修改日期')
+    external_id: Mapped[str] = mapped_column(sa.String(255), default='', sort_order=2, comment='外部id')
+    industry_type: Mapped[int] = mapped_column(sa.Integer(), default=0, sort_order=3, comment='评分规则代码 0 通用 1 教育')
+    chat_history: Mapped[str] = mapped_column(sa.TEXT(), default='', sort_order=4, comment='通话记录')
+    manual_intent: Mapped[int | None] = mapped_column(mysql.TINYINT(), default=None, sort_order=5, comment='人工意向度 1 有意向 2 无意向 3 其他')
+    llm_intent: Mapped[int | None] = mapped_column(mysql.TINYINT(), default=None, sort_order=6, comment='大模型意向度 1 有意向 2 无意向 3 不确定')
+    org_id: Mapped[int] = mapped_column(sa.BIGINT(), default=0, sort_order=7, comment='机构 ID')
+    status: Mapped[int] = mapped_column(mysql.TINYINT(), default=0, sort_order=10, comment='状态 0 入库 1 已判断 2 已回调')
+    request_data: Mapped[dict | None] = mapped_column(sa.JSON(), default=None, sort_order=12, comment='')
+    response_data: Mapped[dict | None] = mapped_column(sa.JSON(), default=None, sort_order=13, comment='')

+ 1 - 2
model/records.py

@@ -1,7 +1,6 @@
 from sqlalchemy import BigInteger, Column, JSON, String, TIMESTAMP, Text, text
 from sqlalchemy import BigInteger, Column, JSON, String, TIMESTAMP, Text, text
 from sqlalchemy.dialects.mysql import TINYINT, VARCHAR
 from sqlalchemy.dialects.mysql import TINYINT, VARCHAR
-from sqlalchemy.orm import Mapped, declarative_base, mapped_column
-from sqlalchemy.orm.base import Mapped
+from sqlalchemy.orm import declarative_base, mapped_column
 
 
 Base = declarative_base()
 Base = declarative_base()
 
 

BIN
requirements.txt