ishaq101 commited on
Commit
f3bdba1
·
1 Parent(s): e22b3b4

[KM-383] [CEX] [AI] Deployment AI Engine / BE

Browse files
.streamlit/config.toml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [server]
2
+ enableXsrfProtection = false
3
+ enableCORS = false
4
+ maxUploadSize = 3
5
+
6
+ [theme]
7
+ base="light"
externals/observability/langfuse.py CHANGED
@@ -6,11 +6,11 @@ from langfuse import Langfuse
6
  from langchain_core.callbacks.base import BaseCallbackHandler
7
 
8
 
9
- langfuse = Langfuse(
10
- secret_key=os.environ.get('ss__langfuse__secret_key'),
11
- public_key=os.environ.get('ss__langfuse__public_key'),
12
- host=os.environ.get('langfuse__host'),
13
- )
14
 
15
 
16
  langfuse_handler = BaseCallbackHandler()
 
6
  from langchain_core.callbacks.base import BaseCallbackHandler
7
 
8
 
9
+ # langfuse = Langfuse(
10
+ # secret_key=os.environ.get('ss__langfuse__secret_key'),
11
+ # public_key=os.environ.get('ss__langfuse__public_key'),
12
+ # host=os.environ.get('langfuse__host'),
13
+ # )
14
 
15
 
16
  langfuse_handler = BaseCallbackHandler()
pyproject.toml CHANGED
@@ -29,3 +29,8 @@ dependencies = [
29
  "sqlalchemy>=2.0.45",
30
  "uvicorn>=0.40.0",
31
  ]
 
 
 
 
 
 
29
  "sqlalchemy>=2.0.45",
30
  "uvicorn>=0.40.0",
31
  ]
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "streamlit>=1.54.0",
36
+ ]
services/agentic/profile_scoring.py CHANGED
@@ -1,4 +1,5 @@
1
  import traceback
 
2
  from typing import List
3
  from langchain_core.prompts import ChatPromptTemplate
4
 
@@ -6,7 +7,8 @@ from config.constant import ProfileFieldTypes
6
  # from externals.databases.pg_crud import get_criteria_id, create_cv_filter, create_cv_matching, get_matching_id, create_cv_score, get_scoring_id
7
  from externals.databases.pg_models import CVProfile, CVWeight, CVFilter, CVScore, CVMatching
8
  from services.llms.LLM import model_4o_2
9
- from services.base.BaseGenerator import BaseAIGenerator, MetadataObservability
 
10
  from services.models.data_model import AIProfile
11
  from services.models.data_model import (
12
  AIMatchProfile,
@@ -89,13 +91,14 @@ def comparison_parser(profile:AIProfile, criteria:Criteria):
89
  for k, v in criteria.items():
90
  print(f" key comparison: {k}")
91
  op = helper_get_operator(k)
92
- if 'gpa' in k or 'yoe' in k:
93
  judges = helper_judge_scoring(a_profile=profile.get(k),
94
  b_criteria=v,
95
  rules=op)
 
96
  else:
97
  judges = '???'
98
- comparison += f"\n| {k} | {profile.get(k)} | {v} | {op} | {judges} |"
99
  return comparison
100
 
101
 
@@ -403,14 +406,15 @@ class AgenticScoringService:
403
  llm = model_4o_2.with_structured_output(AIMatchProfile)
404
 
405
  gen_ai = BaseAIGenerator(
406
- task_name=self._ai_matching.__name__,
407
  prompt=prompt,
408
  llm=llm,
409
  input_llm=input_llm,
410
  metadata_observability=MetadataObservability(
411
  fullname=self.user.full_name,
412
- task_id=str(self.user.user_id),
413
  agent=self._ai_matching.__name__,
 
414
  )
415
  )
416
 
 
1
  import traceback
2
+ from uuid import uuid4
3
  from typing import List
4
  from langchain_core.prompts import ChatPromptTemplate
5
 
 
7
  # from externals.databases.pg_crud import get_criteria_id, create_cv_filter, create_cv_matching, get_matching_id, create_cv_score, get_scoring_id
8
  from externals.databases.pg_models import CVProfile, CVWeight, CVFilter, CVScore, CVMatching
9
  from services.llms.LLM import model_4o_2
10
+ # from services.base.BaseGenerator import BaseAIGenerator, MetadataObservability
11
+ from services.base.BaseGenerator_v2 import BaseAIGenerator, MetadataObservability
12
  from services.models.data_model import AIProfile
13
  from services.models.data_model import (
14
  AIMatchProfile,
 
91
  for k, v in criteria.items():
92
  print(f" key comparison: {k}")
93
  op = helper_get_operator(k)
94
+ if ('gpa' in k or 'yoe' in k) and (profile.get(k) is not None) and (v is not None):
95
  judges = helper_judge_scoring(a_profile=profile.get(k),
96
  b_criteria=v,
97
  rules=op)
98
+ comparison += f"\n| {k} | {profile.get(k)} | {v} | {op} | {judges} |"
99
  else:
100
  judges = '???'
101
+ comparison += f"\n| {k} | {profile.get(k)} | NA | NA | False |"
102
  return comparison
103
 
104
 
 
406
  llm = model_4o_2.with_structured_output(AIMatchProfile)
407
 
408
  gen_ai = BaseAIGenerator(
409
+ task_name="ai matching",
410
  prompt=prompt,
411
  llm=llm,
412
  input_llm=input_llm,
413
  metadata_observability=MetadataObservability(
414
  fullname=self.user.full_name,
415
+ task_id=str(uuid4()),
416
  agent=self._ai_matching.__name__,
417
+ user_id=self.user.email
418
  )
419
  )
420
 
services/base/BaseGenerator.py CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  from pydantic import BaseModel
2
  from langchain_core.prompts import ChatPromptTemplate
3
  from langchain_openai import AzureChatOpenAI
@@ -13,6 +16,7 @@ from typing import Dict
13
  from services.llms.LLM import model_5mini, model_4omini
14
  from utils.decorator import trace_runtime
15
  from utils.logger import get_logger
 
16
 
17
  logger = get_logger("base generator")
18
 
@@ -21,6 +25,7 @@ class MetadataObservability(BaseModel):
21
  fullname: str
22
  task_id: str
23
  agent: str
 
24
 
25
 
26
  class BaseAIGenerator:
@@ -31,26 +36,44 @@ class BaseAIGenerator:
31
  metadata_observability: MetadataObservability,
32
  llm: AzureChatOpenAI = model_5mini | model_4omini,
33
  ):
34
- self.name = task_name
35
  self.llm = llm
36
  self.prompt = prompt
37
  self.input_llm = input_llm
38
- self.metadata_observability = metadata_observability
39
 
40
- def _get_langfuse_handler(self):
41
  try:
42
- import os
43
- from config.constant import LangfuseConstants # adjust import path if needed
44
  os.environ["LANGFUSE_PUBLIC_KEY"] = LangfuseConstants.PUBLIC_KEY
45
  os.environ["LANGFUSE_SECRET_KEY"] = LangfuseConstants.SECRET_KEY
46
  os.environ["LANGFUSE_HOST"] = LangfuseConstants.HOST or "https://us.cloud.langfuse.com"
47
-
48
- from langfuse.langchain import CallbackHandler
49
- return CallbackHandler()
50
  except Exception as e:
51
  logger.warning(f"⚠️ Langfuse unavailable, skipping observability: {e}")
52
  return None
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  @retry(
55
  reraise=True,
56
  stop=stop_after_attempt(2),
@@ -72,35 +95,60 @@ class BaseAIGenerator:
72
  @trace_runtime
73
  async def agenerate(self):
74
  try:
75
- handler = self._get_langfuse_handler()
76
- config = {"callbacks": [handler]} if handler else {}
77
  chain = self.prompt | self.llm
78
-
79
- output = await self._asafe_invoke(
80
- chain=chain,
81
- input_llm=self.input_llm,
82
- config=config,
83
- )
84
- return output
85
-
86
- except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  logger.exception("❌ BaseGenerator agenerate error")
88
  return None
89
 
90
  @trace_runtime
91
  async def generate(self):
92
  try:
93
- handler = self._get_langfuse_handler()
94
- config = {"callbacks": [handler]} if handler else {}
95
  chain = self.prompt | self.llm
96
-
97
- output = self._safe_invoke(
98
- chain=chain,
99
- input_llm=self.input_llm,
100
- config=config,
101
- )
102
- return output
103
-
104
- except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  logger.exception("❌ BaseGenerator generate error")
106
  return None
 
1
+ import os
2
+ from config.constant import LangfuseConstants
3
+ from langfuse.langchain import CallbackHandler
4
  from pydantic import BaseModel
5
  from langchain_core.prompts import ChatPromptTemplate
6
  from langchain_openai import AzureChatOpenAI
 
16
  from services.llms.LLM import model_5mini, model_4omini
17
  from utils.decorator import trace_runtime
18
  from utils.logger import get_logger
19
+ from langfuse import get_client, Langfuse
20
 
21
  logger = get_logger("base generator")
22
 
 
25
  fullname: str
26
  task_id: str
27
  agent: str
28
+ user_id: str
29
 
30
 
31
  class BaseAIGenerator:
 
36
  metadata_observability: MetadataObservability,
37
  llm: AzureChatOpenAI = model_5mini | model_4omini,
38
  ):
39
+ self.metadata_observability = metadata_observability
40
  self.llm = llm
41
  self.prompt = prompt
42
  self.input_llm = input_llm
43
+ self.name = task_name
44
 
45
+ def _get_langfuse_client(self):
46
  try:
 
 
47
  os.environ["LANGFUSE_PUBLIC_KEY"] = LangfuseConstants.PUBLIC_KEY
48
  os.environ["LANGFUSE_SECRET_KEY"] = LangfuseConstants.SECRET_KEY
49
  os.environ["LANGFUSE_HOST"] = LangfuseConstants.HOST or "https://us.cloud.langfuse.com"
50
+ langfuse = Langfuse()
51
+ return langfuse
 
52
  except Exception as e:
53
  logger.warning(f"⚠️ Langfuse unavailable, skipping observability: {e}")
54
  return None
55
 
56
+ def _get_langfuse_config(self):
57
+ try:
58
+ os.environ["LANGFUSE_PUBLIC_KEY"] = LangfuseConstants.PUBLIC_KEY
59
+ os.environ["LANGFUSE_SECRET_KEY"] = LangfuseConstants.SECRET_KEY
60
+ os.environ["LANGFUSE_HOST"] = LangfuseConstants.HOST or "https://us.cloud.langfuse.com"
61
+
62
+ handler = CallbackHandler(update_trace=True)
63
+
64
+ return {
65
+ "callbacks": [handler],
66
+ "metadata": {
67
+ "langfuse_session_id": self.metadata_observability.task_id,
68
+ "langfuse_user_id": self.metadata_observability.fullname,
69
+ "langfuse_tags": [self.metadata_observability.agent],
70
+ "langfuse_trace_name": self.name,
71
+ },
72
+ }
73
+ except Exception as e:
74
+ logger.warning(f"⚠️ Langfuse unavailable, skipping observability: {e}")
75
+ return {}
76
+
77
  @retry(
78
  reraise=True,
79
  stop=stop_after_attempt(2),
 
95
  @trace_runtime
96
  async def agenerate(self):
97
  try:
98
+ config = self._get_langfuse_config()
 
99
  chain = self.prompt | self.llm
100
+ langfuse_client = self._get_langfuse_client()
101
+ trace_id = Langfuse.create_trace_id(seed=self.metadata_observability.task_id)
102
+
103
+ with langfuse_client.start_as_current_observation(
104
+ as_type='generation',
105
+ name=self.name,
106
+ metadata=self.metadata_observability,
107
+ input=self.input_llm,
108
+ trace_context={"trace_id": trace_id},
109
+ ) as span:
110
+ span.update_trace(
111
+ name=self.name,
112
+ user_id=self.metadata_observability.user_id)
113
+ output = await self._asafe_invoke(
114
+ chain=chain,
115
+ input_llm=self.input_llm,
116
+ config=config,
117
+ )
118
+ span.update_trace(output=output)
119
+ return output
120
+
121
+
122
+ except Exception:
123
  logger.exception("❌ BaseGenerator agenerate error")
124
  return None
125
 
126
  @trace_runtime
127
  async def generate(self):
128
  try:
129
+ config = self._get_langfuse_config()
 
130
  chain = self.prompt | self.llm
131
+ langfuse_client = self._get_langfuse_client()
132
+ trace_id = Langfuse.create_trace_id(seed=self.metadata_observability.task_id)
133
+
134
+ with langfuse_client.start_as_current_observation(
135
+ as_type='generation',
136
+ name=self.name,
137
+ metadata=self.metadata_observability,
138
+ input=self.input_llm,
139
+ trace_context={"trace_id": trace_id},
140
+ ) as span:
141
+ span.update_trace(
142
+ name=self.name,
143
+ user_id=self.metadata_observability.user_id)
144
+ output = self._safe_invoke(
145
+ chain=chain,
146
+ input_llm=self.input_llm,
147
+ config=config,
148
+ )
149
+ span.update_trace(output=output)
150
+ return output
151
+
152
+ except Exception:
153
  logger.exception("❌ BaseGenerator generate error")
154
  return None
services/base/BaseGenerator_v2.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langfuse import get_client, Langfuse, propagate_attributes
2
+ from langfuse.langchain import CallbackHandler
3
+ import os
4
+ from config.constant import LangfuseConstants
5
+ from pydantic import BaseModel
6
+ from langchain_core.prompts import ChatPromptTemplate
7
+ from langchain_openai import AzureChatOpenAI
8
+ from tenacity import (
9
+ retry,
10
+ stop_after_attempt,
11
+ wait_exponential,
12
+ retry_if_exception_type
13
+ )
14
+ from typing import Dict
15
+ from services.llms.LLM import model_5mini, model_4omini
16
+ from utils.decorator import trace_runtime
17
+ from utils.logger import get_logger
18
+
19
+ logger = get_logger("base generator")
20
+
21
+ # Set environment variables at module level
22
+ os.environ["LANGFUSE_PUBLIC_KEY"] = LangfuseConstants.PUBLIC_KEY
23
+ os.environ["LANGFUSE_SECRET_KEY"] = LangfuseConstants.SECRET_KEY
24
+ os.environ["LANGFUSE_HOST"] = LangfuseConstants.HOST or "https://us.cloud.langfuse.com"
25
+
26
+
27
+ class MetadataObservability(BaseModel):
28
+ fullname: str
29
+ task_id: str
30
+ agent: str
31
+ user_id: str
32
+
33
+
34
+ class BaseAIGenerator:
35
+ def __init__(self,
36
+ task_name: str,
37
+ prompt: ChatPromptTemplate,
38
+ input_llm: Dict,
39
+ metadata_observability: MetadataObservability,
40
+ llm: AzureChatOpenAI = model_5mini | model_4omini,
41
+ ):
42
+ self.metadata_observability = metadata_observability
43
+ self.llm = llm
44
+ self.prompt = prompt
45
+ self.input_llm = input_llm
46
+ self.name = task_name
47
+
48
+ def _get_langfuse_client(self):
49
+ try:
50
+ # Environment variables already set at module level
51
+ return get_client()
52
+ except Exception as e:
53
+ logger.warning(f"⚠️ Langfuse unavailable, skipping observability: {e}")
54
+ return None
55
+
56
+ def _get_langfuse_config(self):
57
+ try:
58
+ # Environment variables already set at module level
59
+ handler = CallbackHandler()
60
+
61
+ return {
62
+ "callbacks": [handler],
63
+ "metadata": {
64
+ "langfuse_session_id": self.metadata_observability.task_id,
65
+ "langfuse_user_id": self.metadata_observability.user_id,
66
+ "langfuse_tags": [self.metadata_observability.agent],
67
+ },
68
+ }
69
+ except Exception as e:
70
+ logger.warning(f"⚠️ Langfuse unavailable, skipping observability: {e}")
71
+ return {}
72
+
73
+ @retry(
74
+ reraise=True,
75
+ stop=stop_after_attempt(2),
76
+ wait=wait_exponential(multiplier=1, min=1, max=5),
77
+ retry=retry_if_exception_type(Exception)
78
+ )
79
+ async def _asafe_invoke(self, chain, input_llm, config):
80
+ return await chain.ainvoke(input_llm, config=config)
81
+
82
+ @retry(
83
+ reraise=True,
84
+ stop=stop_after_attempt(2),
85
+ wait=wait_exponential(multiplier=1, min=1, max=5),
86
+ retry=retry_if_exception_type(Exception)
87
+ )
88
+ def _safe_invoke(self, chain, input_llm, config):
89
+ return chain.invoke(input_llm, config=config)
90
+
91
+ @trace_runtime
92
+ async def agenerate(self):
93
+ try:
94
+ config = self._get_langfuse_config()
95
+ chain = self.prompt | self.llm
96
+ langfuse_client = self._get_langfuse_client()
97
+
98
+ if not langfuse_client:
99
+ return await self._asafe_invoke(chain, self.input_llm, config)
100
+
101
+ trace_id = Langfuse.create_trace_id(seed=self.metadata_observability.task_id)
102
+
103
+ with langfuse_client.start_as_current_observation(
104
+ as_type="generation",
105
+ name=self.name,
106
+ trace_context={"trace_id": trace_id},
107
+ metadata=self.metadata_observability,
108
+ ) as span:
109
+ with propagate_attributes(
110
+ user_id=self.metadata_observability.user_id,
111
+ session_id=self.metadata_observability.task_id,
112
+ tags=[self.metadata_observability.agent],
113
+ ):
114
+ span.update_trace(
115
+ input=self.input_llm,
116
+ )
117
+
118
+ output = await self._asafe_invoke(
119
+ chain=chain,
120
+ input_llm=self.input_llm,
121
+ config=config,
122
+ )
123
+
124
+ span.update_trace(output=output)
125
+ return output
126
+
127
+ except Exception:
128
+ logger.exception("❌ BaseGenerator agenerate error")
129
+ return None
130
+
131
+ @trace_runtime
132
+ def generate(self):
133
+ try:
134
+ config = self._get_langfuse_config()
135
+ chain = self.prompt | self.llm
136
+ langfuse_client = self._get_langfuse_client()
137
+
138
+ if not langfuse_client:
139
+ return self._safe_invoke(chain, self.input_llm, config)
140
+
141
+ trace_id = Langfuse.create_trace_id(seed=self.metadata_observability.task_id)
142
+
143
+ with langfuse_client.start_as_current_observation(
144
+ as_type="generation",
145
+ name=self.name,
146
+ trace_context={"trace_id": trace_id},
147
+ metadata=self.metadata_observability,
148
+ ) as span:
149
+ with propagate_attributes(
150
+ user_id=self.metadata_observability.user_id,
151
+ session_id=self.metadata_observability.task_id,
152
+ tags=[self.metadata_observability.agent],
153
+ ):
154
+ span.update_trace(
155
+ input=self.input_llm,
156
+ )
157
+
158
+ output = self._safe_invoke(
159
+ chain=chain,
160
+ input_llm=self.input_llm,
161
+ config=config,
162
+ )
163
+
164
+ span.update_trace(output=output)
165
+ return output
166
+
167
+ except Exception:
168
+ logger.exception("❌ BaseGenerator generate error")
169
+ return None
services/knowledge/extract_profile.py CHANGED
@@ -5,7 +5,8 @@ from typing import TypedDict, List, Dict, Union
5
  from langchain_core.prompts import ChatPromptTemplate
6
 
7
  from services.models.data_model import AIProfile, RawProfile, Profiles
8
- from services.base.BaseGenerator import BaseAIGenerator, MetadataObservability
 
9
  from services.llms.LLM import model_5mini, model_4o_2
10
  from utils.decorator import trace_runtime
11
  from utils.logger import get_logger
@@ -16,6 +17,7 @@ logger = get_logger("profile extraction")
16
  from sqlalchemy.ext.asyncio import AsyncSession
17
  from externals.databases.pg_crud import (
18
  get_file_by_filename,
 
19
  mark_file_extracted,
20
  create_profile,
21
  )
@@ -47,6 +49,9 @@ class KnowledgeExtractService:
47
 
48
  if file.is_extracted:
49
  logger.info(f"ℹ️ File already extracted: {filename}")
 
 
 
50
 
51
  # 2️⃣ Download PDF
52
  pdf_bytes: bytes = await download_blob_by_filename(
@@ -133,8 +138,12 @@ class KnowledgeExtractService:
133
 
134
  **Instructions**:
135
  1. Read the provided CV and extract information needed based on expected output.
136
- 2. Reformat the extracted info using correct word spacing.
137
- 3. Do not verbose, just return the final answer.
 
 
 
 
138
  """.strip()
139
  prompt = ChatPromptTemplate.from_template(extract_one_profile_prompt)
140
  input_llm = {
@@ -144,14 +153,15 @@ class KnowledgeExtractService:
144
  llm = model_4o_2.with_structured_output(AIProfile)
145
 
146
  gen_ai = BaseAIGenerator(
147
- task_name=self.extract.__name__,
148
  prompt=prompt,
149
  input_llm=input_llm,
150
  llm=llm,
151
  metadata_observability=MetadataObservability(
152
  fullname=self.user.full_name,
153
- task_id=str(self.user.user_id),
154
  agent=self.extract.__name__,
 
155
  )
156
  )
157
  result = await gen_ai.agenerate()
 
5
  from langchain_core.prompts import ChatPromptTemplate
6
 
7
  from services.models.data_model import AIProfile, RawProfile, Profiles
8
+ # from services.base.BaseGenerator import BaseAIGenerator, MetadataObservability
9
+ from services.base.BaseGenerator_v2 import BaseAIGenerator, MetadataObservability
10
  from services.llms.LLM import model_5mini, model_4o_2
11
  from utils.decorator import trace_runtime
12
  from utils.logger import get_logger
 
17
  from sqlalchemy.ext.asyncio import AsyncSession
18
  from externals.databases.pg_crud import (
19
  get_file_by_filename,
20
+ get_profile_by_filename,
21
  mark_file_extracted,
22
  create_profile,
23
  )
 
49
 
50
  if file.is_extracted:
51
  logger.info(f"ℹ️ File already extracted: {filename}")
52
+ existing = await get_profile_by_filename(self.db, filename=filename, current_user=self.user)
53
+ if existing:
54
+ return existing
55
 
56
  # 2️⃣ Download PDF
57
  pdf_bytes: bytes = await download_blob_by_filename(
 
138
 
139
  **Instructions**:
140
  1. Read the provided CV and extract information needed based on expected output.
141
+ 2. Be Careful when to extract information for gpa_edu_1, univ_edu_1, major_edu_1 or gpa_edu_2, univ_edu_2, major_edu_2 or gpa_edu_3, univ_edu_3, major_edu_3
142
+ ..._edu_1 is only for bachelor/undergraduate/sarjana degree
143
+ ..._edu_2 is only for master/postgraduate degree
144
+ ..._edu_3 is only for doctor/phd degree
145
+ 3. Reformat the extracted info using correct word spacing.
146
+ 4. Do not verbose, just return the final answer.
147
  """.strip()
148
  prompt = ChatPromptTemplate.from_template(extract_one_profile_prompt)
149
  input_llm = {
 
153
  llm = model_4o_2.with_structured_output(AIProfile)
154
 
155
  gen_ai = BaseAIGenerator(
156
+ task_name="extract profile",
157
  prompt=prompt,
158
  input_llm=input_llm,
159
  llm=llm,
160
  metadata_observability=MetadataObservability(
161
  fullname=self.user.full_name,
162
+ task_id=str(uuid4()),
163
  agent=self.extract.__name__,
164
+ user_id=self.user.email,
165
  )
166
  )
167
  result = await gen_ai.agenerate()
services/models/data_model.py CHANGED
@@ -32,15 +32,15 @@ class AIProfile(TypedDict):
32
  fullname: str = Field(description="Fullname of the candidate", default="-")
33
  # gender: str = Field(description="Gender of the candidate, if available", default="null")
34
  # age: int = Field(description="Age in number")
35
- gpa_edu_1: float = Field(description="""GPA of candidate's bachelor degree, if exists.""", default=0)
36
  univ_edu_1: str = Field(description="""University where candidate take bachelor degree, if exists.""", default="-")
37
  major_edu_1: str = Field(description="""Major of candidate's bachelor degree, if exists.""", default="-")
38
 
39
- gpa_edu_2: float = Field(description="""GPA of candidate's master degree, if exists.""", default=0)
40
  univ_edu_2: str = Field(description="""University where candidate take master degree, if exists.""", default="-")
41
  major_edu_2: str = Field(description="""Major of candidate's master degree, if exists.""", default="-")
42
 
43
- gpa_edu_3: float = Field(description="""GPA of candidate's doctoral or phd degree, if exists.""", default=0)
44
  univ_edu_3: str = Field(description="""University where candidate take doctoral or phd degree, if exists.""", default="-")
45
  major_edu_3: str = Field(description="""Major of candidate's doctoral or phd degree, if exists.""", default="-")
46
 
 
32
  fullname: str = Field(description="Fullname of the candidate", default="-")
33
  # gender: str = Field(description="Gender of the candidate, if available", default="null")
34
  # age: int = Field(description="Age in number")
35
+ gpa_edu_1: float = Field(description="""GPA of candidate's bachelor degree (same like sarjana, s1, undergradute), if exists.""", default=0)
36
  univ_edu_1: str = Field(description="""University where candidate take bachelor degree, if exists.""", default="-")
37
  major_edu_1: str = Field(description="""Major of candidate's bachelor degree, if exists.""", default="-")
38
 
39
+ gpa_edu_2: float = Field(description="""GPA of candidate's master degree (same like master, s2, postgraduate), if exists.""", default=0)
40
  univ_edu_2: str = Field(description="""University where candidate take master degree, if exists.""", default="-")
41
  major_edu_2: str = Field(description="""Major of candidate's master degree, if exists.""", default="-")
42
 
43
+ gpa_edu_3: float = Field(description="""GPA of candidate's doctoral or phd degree (same like phd, s3, doctoral), if exists.""", default=0)
44
  univ_edu_3: str = Field(description="""University where candidate take doctoral or phd degree, if exists.""", default="-")
45
  major_edu_3: str = Field(description="""Major of candidate's doctoral or phd degree, if exists.""", default="-")
46
 
streamlit_app.py ADDED
@@ -0,0 +1,725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import dotenv
3
+ dotenv.load_dotenv()
4
+ import json
5
+ import requests
6
+ import streamlit as st
7
+
8
+ # ─────────────────────────────────────────────
9
+ # CONFIG
10
+ # ─────────────────────────────────────────────
11
+ BASE_URL = os.environ.get("BACKEND_BASE_URL", "http://localhost:8000")
12
+
13
+ st.set_page_config(
14
+ page_title="Candidate Explorer",
15
+ page_icon="🔍",
16
+ layout="wide",
17
+ initial_sidebar_state="expanded",
18
+ )
19
+
20
+ # ─────────────────────────────────────────────
21
+ # GLOBAL CSS
22
+ # ─────────────────────────────────────────────
23
+ st.markdown(
24
+ """
25
+ <style>
26
+ /* ── Global ─────────────────────────────── */
27
+ html, body, [class*="css"] {
28
+ font-family: 'Inter', 'Segoe UI', sans-serif;
29
+ color: #333e4a;
30
+ background-color: #ffffff;
31
+ }
32
+
33
+ /* ── Sidebar ─────────────────────────────── */
34
+ [data-testid="stSidebar"] {
35
+ background-color: #f4f6ff;
36
+ border-right: 1px solid #c7cef5;
37
+ }
38
+ [data-testid="stSidebar"] h1,
39
+ [data-testid="stSidebar"] h2,
40
+ [data-testid="stSidebar"] h3,
41
+ [data-testid="stSidebar"] label {
42
+ color: #435cdc !important;
43
+ }
44
+
45
+ /* ── Buttons ─────────────────────────────── */
46
+ .stButton > button {
47
+ background-color: #435cdc;
48
+ color: #ffffff;
49
+ border: none;
50
+ border-radius: 8px;
51
+ padding: 0.5rem 1.25rem;
52
+ font-weight: 600;
53
+ transition: background-color 0.2s ease;
54
+ }
55
+ .stButton > button:hover {
56
+ background-color: #7b8de7;
57
+ color: #ffffff;
58
+ }
59
+ .stButton > button:focus {
60
+ outline: 2px solid #c7cef5;
61
+ }
62
+
63
+ /* ── Tabs ─────────────────────────────────── */
64
+ [data-baseweb="tab-list"] {
65
+ gap: 8px;
66
+ border-bottom: 2px solid #c7cef5;
67
+ }
68
+ [data-baseweb="tab"] {
69
+ border-radius: 8px 8px 0 0;
70
+ padding: 0.5rem 1.25rem;
71
+ font-weight: 600;
72
+ color: #7b8de7;
73
+ background: transparent;
74
+ }
75
+ [aria-selected="true"][data-baseweb="tab"] {
76
+ color: #435cdc !important;
77
+ border-bottom: 3px solid #435cdc !important;
78
+ background: #f4f6ff;
79
+ }
80
+
81
+ /* ── Inputs ──────────────────────────────── */
82
+ [data-testid="stTextInput"] input,
83
+ [data-testid="stSelectbox"] select,
84
+ textarea {
85
+ border-radius: 8px !important;
86
+ border: 1.5px solid #c7cef5 !important;
87
+ color: #333e4a !important;
88
+ }
89
+ [data-testid="stTextInput"] input:focus,
90
+ textarea:focus {
91
+ border-color: #435cdc !important;
92
+ box-shadow: 0 0 0 2px #c7cef5;
93
+ }
94
+
95
+ /* ── File uploader ───────────────────────── */
96
+ [data-testid="stFileUploader"] {
97
+ border: 2px dashed #7b8de7;
98
+ border-radius: 10px;
99
+ background: #f4f6ff;
100
+ padding: 1rem;
101
+ }
102
+
103
+ /* ── Metric cards ────────────────────────── */
104
+ .scorecard-wrap {
105
+ display: flex;
106
+ gap: 1rem;
107
+ margin-bottom: 1.5rem;
108
+ }
109
+ .scorecard-card {
110
+ flex: 1;
111
+ background: #f4f6ff;
112
+ border: 1.5px solid #c7cef5;
113
+ border-radius: 12px;
114
+ padding: 1.25rem 1.5rem;
115
+ box-shadow: 0 2px 8px rgba(67,92,220,0.07);
116
+ text-align: center;
117
+ }
118
+ .scorecard-card .sc-label {
119
+ font-size: 0.82rem;
120
+ font-weight: 600;
121
+ color: #7b8de7;
122
+ text-transform: uppercase;
123
+ letter-spacing: 0.04em;
124
+ margin-bottom: 0.4rem;
125
+ }
126
+ .scorecard-card .sc-value {
127
+ font-size: 2rem;
128
+ font-weight: 800;
129
+ color: #435cdc;
130
+ line-height: 1.1;
131
+ }
132
+ .scorecard-card .sc-sub {
133
+ font-size: 0.78rem;
134
+ color: #7b8de7;
135
+ margin-top: 0.2rem;
136
+ }
137
+
138
+ /* ── Badge ───────────────────────────────── */
139
+ .badge-success {
140
+ background: #c7cef5; color: #435cdc;
141
+ border-radius: 99px; padding: 2px 10px;
142
+ font-size: 0.78rem; font-weight: 700;
143
+ }
144
+ .badge-warn {
145
+ background: #fff3c4; color: #a07c00;
146
+ border-radius: 99px; padding: 2px 10px;
147
+ font-size: 0.78rem; font-weight: 700;
148
+ }
149
+ .badge-error {
150
+ background: #fde8e8; color: #c0392b;
151
+ border-radius: 99px; padding: 2px 10px;
152
+ font-size: 0.78rem; font-weight: 700;
153
+ }
154
+
155
+ /* ── Section header ──────────────────────── */
156
+ .section-title {
157
+ font-size: 1.15rem;
158
+ font-weight: 700;
159
+ color: #435cdc;
160
+ margin-bottom: 0.75rem;
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 0.4rem;
164
+ }
165
+ .section-title::after {
166
+ content: '';
167
+ flex: 1;
168
+ height: 2px;
169
+ background: linear-gradient(90deg, #c7cef5, transparent);
170
+ margin-left: 0.5rem;
171
+ }
172
+
173
+ /* ── Login card ──────────────────────────── */
174
+ .login-card {
175
+ max-width: 420px;
176
+ margin: 4rem auto;
177
+ padding: 2.5rem 2rem;
178
+ background: #ffffff;
179
+ border-radius: 16px;
180
+ box-shadow: 0 4px 32px rgba(67,92,220,0.13);
181
+ border: 1.5px solid #c7cef5;
182
+ }
183
+ .login-card h1 {
184
+ color: #435cdc;
185
+ font-size: 1.8rem;
186
+ font-weight: 800;
187
+ margin-bottom: 0.25rem;
188
+ }
189
+ .login-card p {
190
+ color: #7b8de7;
191
+ font-size: 0.95rem;
192
+ margin-bottom: 1.5rem;
193
+ }
194
+
195
+ /* ── Divider ─────────────────────────────── */
196
+ hr.styled { border: none; border-top: 1.5px solid #c7cef5; margin: 1.2rem 0; }
197
+
198
+ /* ── JSON output ─────────────────────────── */
199
+ .profile-json {
200
+ background: #f4f6ff;
201
+ border-radius: 10px;
202
+ border: 1.5px solid #c7cef5;
203
+ padding: 1rem 1.25rem;
204
+ font-size: 0.85rem;
205
+ color: #333e4a;
206
+ white-space: pre-wrap;
207
+ word-break: break-word;
208
+ max-height: 500px;
209
+ overflow-y: auto;
210
+ }
211
+
212
+ /* ── Table ───────────────────────────────── */
213
+ [data-testid="stDataFrame"] {
214
+ border-radius: 10px;
215
+ overflow: hidden;
216
+ border: 1.5px solid #c7cef5;
217
+ }
218
+ </style>
219
+ """,
220
+ unsafe_allow_html=True,
221
+ )
222
+
223
+
224
+ # ─────────────────────────────────────────────
225
+ # SESSION STATE INIT
226
+ # ─────────────────────────────────────────────
227
+ for key, default in [
228
+ ("token", None),
229
+ ("user", None),
230
+ ("upload_results", []),
231
+ ("extract_result", None),
232
+ ("user_files", []),
233
+ ]:
234
+ if key not in st.session_state:
235
+ st.session_state[key] = default
236
+
237
+
238
+ # ─────────────────────────────────────────────
239
+ # API HELPERS
240
+ # ─────────────────────────────────────────────
241
+ def _headers():
242
+ return {"Authorization": f"Bearer {st.session_state.token}"}
243
+
244
+
245
+ def api_login(email: str, password: str):
246
+ """POST /admin/login — returns (token, error_msg)"""
247
+ try:
248
+ resp = requests.post(
249
+ f"{BASE_URL}/admin/login",
250
+ data={"username": email, "password": password},
251
+ timeout=15,
252
+ )
253
+ if resp.status_code == 200:
254
+ return resp.json().get("access_token"), None
255
+ detail = resp.json().get("detail", resp.text)
256
+ return None, str(detail)
257
+ except requests.exceptions.ConnectionError:
258
+ return None, "Cannot connect to backend. Check BACKEND_BASE_URL."
259
+ except Exception as e:
260
+ return None, str(e)
261
+
262
+
263
+ def api_get_me():
264
+ """GET /admin/me — returns user dict"""
265
+ try:
266
+ resp = requests.get(f"{BASE_URL}/admin/me", headers=_headers(), timeout=10)
267
+ if resp.status_code == 200:
268
+ return resp.json(), None
269
+ return None, resp.json().get("detail", resp.text)
270
+ except Exception as e:
271
+ return None, str(e)
272
+
273
+
274
+ def api_get_scorecard():
275
+ """GET /file/score_card — returns data dict"""
276
+ try:
277
+ resp = requests.get(f"{BASE_URL}/file/score_card", headers=_headers(), timeout=10)
278
+ if resp.status_code == 200:
279
+ return resp.json().get("data", {}), None
280
+ return None, resp.json().get("detail", resp.text)
281
+ except Exception as e:
282
+ return None, str(e)
283
+
284
+
285
+ def api_upload_files(uploaded_files):
286
+ """POST /file/upload — returns list of results"""
287
+ try:
288
+ files = [
289
+ ("files", (f.name, f.read(), "application/pdf"))
290
+ for f in uploaded_files
291
+ ]
292
+ resp = requests.post(
293
+ f"{BASE_URL}/file/upload",
294
+ headers=_headers(),
295
+ files=files,
296
+ timeout=60,
297
+ )
298
+ if resp.status_code == 201:
299
+ return resp.json().get("files", []), None
300
+ return None, resp.json().get("detail", resp.text)
301
+ except Exception as e:
302
+ return None, str(e)
303
+
304
+
305
+ def api_get_user_files(user_id: str):
306
+ """GET /file/user/{user_id} — returns list of file dicts"""
307
+ try:
308
+ resp = requests.get(
309
+ f"{BASE_URL}/file/user/{user_id}",
310
+ headers=_headers(),
311
+ timeout=10,
312
+ )
313
+ if resp.status_code == 200:
314
+ return resp.json().get("files", []), None
315
+ return None, resp.json().get("detail", resp.text)
316
+ except Exception as e:
317
+ return None, str(e)
318
+
319
+
320
+ def api_extract_profile(filename: str):
321
+ """POST /profile/extract_profile?filename=... — returns profile dict"""
322
+ try:
323
+ resp = requests.post(
324
+ f"{BASE_URL}/profile/extract_profile",
325
+ headers=_headers(),
326
+ params={"filename": filename},
327
+ timeout=120,
328
+ )
329
+ if resp.status_code == 200:
330
+ return resp.json(), None
331
+ return None, resp.json().get("detail", resp.text)
332
+ except Exception as e:
333
+ return None, str(e)
334
+
335
+
336
+ def api_delete_file(filename: str):
337
+ """DELETE /file/{filename}"""
338
+ try:
339
+ resp = requests.delete(
340
+ f"{BASE_URL}/file/{filename}",
341
+ headers=_headers(),
342
+ timeout=15,
343
+ )
344
+ if resp.status_code == 200:
345
+ return True, None
346
+ return False, resp.json().get("detail", resp.text)
347
+ except Exception as e:
348
+ return False, str(e)
349
+
350
+
351
+ # ─────────────────────────────────────────────
352
+ # UI COMPONENTS
353
+ # ─────────────────────────────────────────────
354
+ def render_scorecard():
355
+ sc, err = api_get_scorecard()
356
+ if err:
357
+ st.warning(f"Could not load scorecard: {err}")
358
+ return
359
+
360
+ total_file = sc.get("total_file", 0)
361
+ total_extracted = sc.get("total_extracted", 0)
362
+ pct = sc.get("percent_extracted", 0)
363
+ # percent_extracted may be a float like 75.0 or string "75%"
364
+ if isinstance(pct, str):
365
+ pct_display = pct
366
+ else:
367
+ pct_display = f"{pct:.1f}%"
368
+
369
+ st.markdown(
370
+ f"""
371
+ <div class="scorecard-wrap">
372
+ <div class="scorecard-card">
373
+ <div class="sc-label">📁 Total CVs Uploaded</div>
374
+ <div class="sc-value">{total_file}</div>
375
+ <div class="sc-sub">files in your workspace</div>
376
+ </div>
377
+ <div class="scorecard-card">
378
+ <div class="sc-label">✅ Profiles Extracted</div>
379
+ <div class="sc-value">{total_extracted}</div>
380
+ <div class="sc-sub">structured profiles</div>
381
+ </div>
382
+ <div class="scorecard-card">
383
+ <div class="sc-label">📊 Extraction Rate</div>
384
+ <div class="sc-value" style="color:#dcc343">{pct_display}</div>
385
+ <div class="sc-sub">of uploaded CVs processed</div>
386
+ </div>
387
+ </div>
388
+ """,
389
+ unsafe_allow_html=True,
390
+ )
391
+
392
+
393
+ def render_sidebar():
394
+ user = st.session_state.user or {}
395
+ with st.sidebar:
396
+ st.markdown(
397
+ f"""
398
+ <div style="text-align:center;padding:1rem 0 0.5rem;">
399
+ <div style="font-size:2.5rem;">👤</div>
400
+ <div style="font-weight:800;font-size:1.1rem;color:#435cdc;">
401
+ {user.get('full_name', 'User')}
402
+ </div>
403
+ <div style="font-size:0.82rem;color:#7b8de7;margin-top:2px;">
404
+ {user.get('email', '')}
405
+ </div>
406
+ <span class="badge-success" style="margin-top:6px;display:inline-block;">
407
+ {user.get('role', 'user').upper()}
408
+ </span>
409
+ </div>
410
+ <hr class="styled">
411
+ """,
412
+ unsafe_allow_html=True,
413
+ )
414
+
415
+ # st.markdown(
416
+ # "<div style='font-size:0.78rem;color:#7b8de7;margin-bottom:4px;'>BACKEND</div>",
417
+ # unsafe_allow_html=True,
418
+ # )
419
+ # st.markdown(
420
+ # f"<code style='font-size:0.75rem;color:#435cdc;'>{BASE_URL}</code>",
421
+ # unsafe_allow_html=True,
422
+ # )
423
+
424
+ # st.markdown("<hr class='styled'>", unsafe_allow_html=True)
425
+
426
+ if st.button("🚪 Logout", use_container_width=True):
427
+ for key in ["token", "user", "upload_results", "extract_result", "user_files"]:
428
+ st.session_state[key] = None if key in ("token", "user", "extract_result") else []
429
+ st.rerun()
430
+
431
+
432
+ # ─────────────────────────────────────────────
433
+ # PAGES
434
+ # ─────────────────────────────────────────────
435
+ def page_login():
436
+ # Center the login card
437
+ _, col, _ = st.columns([1, 1.4, 1])
438
+ with col:
439
+ st.markdown(
440
+ """
441
+ <div class="login-card">
442
+ <h1>🔍 Candidate Explorer</h1>
443
+ <p>Sign in to manage and analyze candidate CVs.</p>
444
+ </div>
445
+ """,
446
+ unsafe_allow_html=True,
447
+ )
448
+
449
+ with st.form("login_form", clear_on_submit=False):
450
+ st.markdown(
451
+ "<div class='section-title'>Sign In</div>", unsafe_allow_html=True
452
+ )
453
+ email = st.text_input("Email address", placeholder="you@company.com")
454
+ password = st.text_input("Password", type="password", placeholder="••••••••")
455
+ submitted = st.form_submit_button("Sign In →", use_container_width=True)
456
+
457
+ if submitted:
458
+ if not email or not password:
459
+ st.error("Please enter both email and password.")
460
+ else:
461
+ with st.spinner("Signing in…"):
462
+ token, err = api_login(email, password)
463
+ if err:
464
+ st.error(f"Login failed: {err}")
465
+ else:
466
+ st.session_state.token = token
467
+ user, err2 = api_get_me()
468
+ if err2:
469
+ st.session_state.user = {"email": email, "full_name": email, "role": "user"}
470
+ else:
471
+ st.session_state.user = user
472
+ st.rerun()
473
+
474
+
475
+ def page_main():
476
+ render_sidebar()
477
+
478
+ # ── Page header ──────────────────────────────
479
+ st.markdown(
480
+ "<h1 style='color:#435cdc;font-size:1.75rem;font-weight:800;margin-bottom:0.1rem;'>"
481
+ "🔍 Candidate Explorer"
482
+ "</h1>"
483
+ "<p style='color:#7b8de7;margin-top:0;margin-bottom:1.25rem;font-size:0.95rem;'>"
484
+ "Upload CVs, extract candidate profiles, and track your workspace.</p>",
485
+ unsafe_allow_html=True,
486
+ )
487
+
488
+ # ── Scorecard ─────────────────────────────────
489
+ st.markdown("<div class='section-title'>📊 Dashboard Overview</div>", unsafe_allow_html=True)
490
+ render_scorecard()
491
+
492
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
493
+
494
+ # ── Tabs ──────────────────────────────────────
495
+ tab_upload, tab_extract = st.tabs(["📁 Upload CV", "🧠 Extract Profile"])
496
+
497
+ # ══════════════════════════════════════════
498
+ # TAB 1 — UPLOAD
499
+ # ══════════════════════════════════════════
500
+ with tab_upload:
501
+ st.markdown("<br>", unsafe_allow_html=True)
502
+ st.markdown(
503
+ "<div class='section-title'>Upload Candidate CVs</div>",
504
+ unsafe_allow_html=True,
505
+ )
506
+
507
+ uploaded = st.file_uploader(
508
+ "Drop PDF files here or click to browse",
509
+ type=["pdf"],
510
+ accept_multiple_files=True,
511
+ help="Only PDF files are accepted.",
512
+ )
513
+
514
+ col_btn, col_info = st.columns([1, 3])
515
+ with col_btn:
516
+ do_upload = st.button("⬆️ Upload", use_container_width=True, disabled=not uploaded)
517
+
518
+ if do_upload and uploaded:
519
+ with st.spinner(f"Uploading {len(uploaded)} file(s)…"):
520
+ results, err = api_upload_files(uploaded)
521
+ if err:
522
+ st.error(f"Upload failed: {err}")
523
+ else:
524
+ st.session_state.upload_results = results
525
+ # Refresh user files list
526
+ user = st.session_state.user or {}
527
+ uid = str(user.get("user_id", ""))
528
+ if uid:
529
+ files, _ = api_get_user_files(uid)
530
+ st.session_state.user_files = files or []
531
+ st.rerun()
532
+
533
+ # ── Results table ──
534
+ if st.session_state.upload_results:
535
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
536
+ st.markdown(
537
+ "<div class='section-title'>Upload Results</div>",
538
+ unsafe_allow_html=True,
539
+ )
540
+ for r in st.session_state.upload_results:
541
+ fname = r.get("filename", r.get("name", ""))
542
+ status = r.get("status", "uploaded")
543
+ badge_cls = "badge-success" if "success" in status.lower() or status == "uploaded" else "badge-error"
544
+ st.markdown(
545
+ f"<span class='badge-success'>✓</span>&nbsp;"
546
+ f"<strong style='color:#333e4a;'>{fname}</strong>&nbsp;"
547
+ f"<span class='{badge_cls}'>{status}</span>",
548
+ unsafe_allow_html=True,
549
+ )
550
+
551
+ # ── Existing files ──
552
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
553
+ st.markdown(
554
+ "<div class='section-title'>Your Uploaded Files</div>",
555
+ unsafe_allow_html=True,
556
+ )
557
+
558
+ user = st.session_state.user or {}
559
+ uid = str(user.get("user_id", ""))
560
+
561
+ col_refresh, _ = st.columns([1, 5])
562
+ with col_refresh:
563
+ if st.button("🔄 Refresh List", use_container_width=True):
564
+ if uid:
565
+ files, err = api_get_user_files(uid)
566
+ if err:
567
+ st.warning(f"Could not load files: {err}")
568
+ else:
569
+ st.session_state.user_files = files or []
570
+
571
+ if not st.session_state.user_files and uid:
572
+ # Auto-load on first visit
573
+ files, _ = api_get_user_files(uid)
574
+ st.session_state.user_files = files or []
575
+
576
+ if st.session_state.user_files:
577
+ rows = []
578
+ for f in st.session_state.user_files:
579
+ rows.append(
580
+ {
581
+ "Filename": f.get("filename", ""),
582
+ "Type": f.get("file_type", ""),
583
+ "Extracted": "✅" if f.get("is_extracted") else "⏳",
584
+ "Uploaded": str(f.get("uploaded_at", ""))[:19],
585
+ }
586
+ )
587
+ st.dataframe(rows, use_container_width=True, hide_index=True)
588
+ else:
589
+ st.info("No files uploaded yet.")
590
+
591
+ # ══════════════════════════════════════════
592
+ # TAB 2 — EXTRACT PROFILE
593
+ # ══════════════════════════════════════════
594
+ with tab_extract:
595
+ st.markdown("<br>", unsafe_allow_html=True)
596
+ st.markdown(
597
+ "<div class='section-title'>Extract Structured Profile from CV</div>",
598
+ unsafe_allow_html=True,
599
+ )
600
+
601
+ # Load files if needed
602
+ user = st.session_state.user or {}
603
+ uid = str(user.get("user_id", ""))
604
+ if not st.session_state.user_files and uid:
605
+ files, _ = api_get_user_files(uid)
606
+ st.session_state.user_files = files or []
607
+
608
+ file_options = [f.get("filename", "") for f in st.session_state.user_files if f.get("filename")]
609
+
610
+ if not file_options:
611
+ st.info("No CVs found. Upload files first in the **Upload CV** tab.")
612
+ else:
613
+ col_sel, col_ex = st.columns([3, 1])
614
+ with col_sel:
615
+ chosen = st.selectbox(
616
+ "Select a CV file to extract",
617
+ options=file_options,
618
+ help="Choose a PDF you have already uploaded.",
619
+ )
620
+ with col_ex:
621
+ st.markdown("<div style='margin-top:1.72rem;'></div>", unsafe_allow_html=True)
622
+ do_extract = st.button("🧠 Extract", use_container_width=True)
623
+
624
+ if do_extract and chosen:
625
+ with st.spinner(f"Extracting profile from **{chosen}**… this may take a moment."):
626
+ result, err = api_extract_profile(chosen)
627
+ if err:
628
+ st.error(f"Extraction failed: {err}")
629
+ st.session_state.extract_result = None
630
+ else:
631
+ st.session_state.extract_result = result
632
+ # Refresh files to update is_extracted flag
633
+ if uid:
634
+ files, _ = api_get_user_files(uid)
635
+ st.session_state.user_files = files or []
636
+ st.rerun()
637
+
638
+ # ── Display extracted profile ──
639
+ if st.session_state.extract_result:
640
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
641
+ result = st.session_state.extract_result
642
+
643
+ # Try to highlight key fields
644
+ fullname = result.get("fullname") or result.get("full_name", "")
645
+ if fullname:
646
+ st.markdown(
647
+ f"<div style='background:#c7cef5;border-radius:10px;padding:0.75rem 1.2rem;"
648
+ f"margin-bottom:1rem;'>"
649
+ f"<span style='color:#435cdc;font-weight:800;font-size:1.1rem;'>👤 {fullname}</span>"
650
+ f"</div>",
651
+ unsafe_allow_html=True,
652
+ )
653
+
654
+ col_l, col_r = st.columns(2)
655
+
656
+ with col_l:
657
+ st.markdown("<div class='section-title'>Education</div>", unsafe_allow_html=True)
658
+ for i in range(1, 4):
659
+ univ = result.get(f"univ_edu_{i}", "")
660
+ major = result.get(f"major_edu_{i}", "")
661
+ gpa = result.get(f"gpa_edu_{i}", "")
662
+ if univ or major:
663
+ gpa_str = f" · GPA {gpa}" if gpa else ""
664
+ st.markdown(
665
+ f"<div style='margin-bottom:0.5rem;padding:0.6rem 1rem;"
666
+ f"background:#f4f6ff;border-radius:8px;border-left:3px solid #435cdc;'>"
667
+ f"<strong style='color:#333e4a;'>{univ or '—'}</strong><br>"
668
+ f"<span style='color:#7b8de7;font-size:0.85rem;'>{major or ''}{gpa_str}</span>"
669
+ f"</div>",
670
+ unsafe_allow_html=True,
671
+ )
672
+
673
+ st.markdown("<div class='section-title' style='margin-top:1rem;'>Experience</div>", unsafe_allow_html=True)
674
+ yoe = result.get("yoe")
675
+ domicile = result.get("domicile", "")
676
+ st.markdown(
677
+ f"<div style='padding:0.6rem 1rem;background:#f4f6ff;border-radius:8px;"
678
+ f"border-left:3px solid #dcc343;'>"
679
+ f"<span style='color:#333e4a;font-weight:600;'>Years of Experience:</span> "
680
+ f"<span style='color:#435cdc;font-weight:800;'>{yoe if yoe is not None else '—'}</span><br>"
681
+ f"<span style='color:#333e4a;font-weight:600;'>Domicile:</span> "
682
+ f"<span style='color:#435cdc;'>{domicile or '—'}</span>"
683
+ f"</div>",
684
+ unsafe_allow_html=True,
685
+ )
686
+
687
+ with col_r:
688
+ def _tag_list(label, items, color="#c7cef5", text_color="#435cdc"):
689
+ if not items:
690
+ return
691
+ st.markdown(
692
+ f"<div class='section-title'>{label}</div>",
693
+ unsafe_allow_html=True,
694
+ )
695
+ tags = "".join(
696
+ f"<span style='background:{color};color:{text_color};border-radius:99px;"
697
+ f"padding:3px 10px;font-size:0.78rem;font-weight:600;margin:2px;display:inline-block;'>"
698
+ f"{t}</span>"
699
+ for t in items
700
+ )
701
+ st.markdown(
702
+ f"<div style='margin-bottom:0.75rem;line-height:2;'>{tags}</div>",
703
+ unsafe_allow_html=True,
704
+ )
705
+
706
+ _tag_list("💻 Hard Skills", result.get("hardskills", []))
707
+ _tag_list("🤝 Soft Skills", result.get("softskills", []), "#f4f6ff", "#333e4a")
708
+ _tag_list("🏆 Certifications", result.get("certifications", []), "#fff3c4", "#a07c00")
709
+ _tag_list("🏢 Business Domains", result.get("business_domain", []), "#c7cef5", "#435cdc")
710
+
711
+ # Raw JSON toggle
712
+ with st.expander("📄 Raw JSON response"):
713
+ st.markdown(
714
+ f"<div class='profile-json'>{json.dumps(result, indent=2, default=str)}</div>",
715
+ unsafe_allow_html=True,
716
+ )
717
+
718
+
719
+ # ─────────────────────────────────────────────
720
+ # ROUTER
721
+ # ─────────────────────────────────────────────
722
+ if st.session_state.token:
723
+ page_main()
724
+ else:
725
+ page_login()
utils/decorator.py CHANGED
@@ -5,7 +5,7 @@ import time
5
 
6
  from functools import wraps
7
  from typing import Callable, Any
8
- from sqlalchemy.exc import OperationalError, InterfaceError
9
 
10
  def trace_runtime(func: Callable) -> Callable:
11
  @wraps(func)
 
5
 
6
  from functools import wraps
7
  from typing import Callable, Any
8
+ from sqlalchemy.exc import OperationalError, InterfaceError, PendingRollbackError
9
 
10
  def trace_runtime(func: Callable) -> Callable:
11
  @wraps(func)
uv.lock CHANGED
@@ -100,6 +100,22 @@ wheels = [
100
  { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
101
  ]
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  [[package]]
104
  name = "annotated-doc"
105
  version = "0.0.4"
@@ -245,6 +261,24 @@ wheels = [
245
  { url = "https://files.pythonhosted.org/packages/f5/37/7cd297ff571c4d86371ff024c0e008b37b59e895b28f69444a9b6f94ca1a/bcrypt-3.2.2-cp36-abi3-win_amd64.whl", hash = "sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129", size = 29581, upload-time = "2022-05-01T18:05:57.878Z" },
246
  ]
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  [[package]]
249
  name = "certifi"
250
  version = "2025.11.12"
@@ -678,6 +712,30 @@ wheels = [
678
  { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
679
  ]
680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
  [[package]]
682
  name = "googleapis-common-protos"
683
  version = "1.72.0"
@@ -903,6 +961,33 @@ wheels = [
903
  { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
904
  ]
905
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  [[package]]
907
  name = "langchain"
908
  version = "1.2.0"
@@ -1226,6 +1311,15 @@ wheels = [
1226
  { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
1227
  ]
1228
 
 
 
 
 
 
 
 
 
 
1229
  [[package]]
1230
  name = "numpy"
1231
  version = "2.4.0"
@@ -1666,6 +1760,42 @@ wheels = [
1666
  { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" },
1667
  ]
1668
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1669
  [[package]]
1670
  name = "pyasn1"
1671
  version = "0.6.1"
@@ -1784,6 +1914,19 @@ wheels = [
1784
  { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
1785
  ]
1786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1787
  [[package]]
1788
  name = "pygments"
1789
  version = "2.19.2"
@@ -1947,6 +2090,19 @@ wheels = [
1947
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
1948
  ]
1949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1950
  [[package]]
1951
  name = "regex"
1952
  version = "2025.11.3"
@@ -2118,6 +2274,72 @@ wheels = [
2118
  { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
2119
  ]
2120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2121
  [[package]]
2122
  name = "rsa"
2123
  version = "4.9.1"
@@ -2173,6 +2395,11 @@ dependencies = [
2173
  { name = "uvicorn" },
2174
  ]
2175
 
 
 
 
 
 
2176
  [package.metadata]
2177
  requires-dist = [
2178
  { name = "aiohttp", specifier = ">=3.13.2" },
@@ -2200,6 +2427,9 @@ requires-dist = [
2200
  { name = "uvicorn", specifier = ">=0.40.0" },
2201
  ]
2202
 
 
 
 
2203
  [[package]]
2204
  name = "shellingham"
2205
  version = "1.5.4"
@@ -2218,6 +2448,15 @@ wheels = [
2218
  { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
2219
  ]
2220
 
 
 
 
 
 
 
 
 
 
2221
  [[package]]
2222
  name = "sniffio"
2223
  version = "1.3.1"
@@ -2268,6 +2507,35 @@ wheels = [
2268
  { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
2269
  ]
2270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2271
  [[package]]
2272
  name = "tenacity"
2273
  version = "9.1.2"
@@ -2317,6 +2585,34 @@ wheels = [
2317
  { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
2318
  ]
2319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2320
  [[package]]
2321
  name = "tqdm"
2322
  version = "4.67.1"
@@ -2455,6 +2751,24 @@ wheels = [
2455
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
2456
  ]
2457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2458
  [[package]]
2459
  name = "watchfiles"
2460
  version = "1.1.1"
 
100
  { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
101
  ]
102
 
103
+ [[package]]
104
+ name = "altair"
105
+ version = "6.0.0"
106
+ source = { registry = "https://pypi.org/simple" }
107
+ dependencies = [
108
+ { name = "jinja2" },
109
+ { name = "jsonschema" },
110
+ { name = "narwhals" },
111
+ { name = "packaging" },
112
+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },
113
+ ]
114
+ sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/184a89bd5feba14ff3c41cfaf1dd8a82c05f5ceedbc92145e17042eb08a4/altair-6.0.0.tar.gz", hash = "sha256:614bf5ecbe2337347b590afb111929aa9c16c9527c4887d96c9bc7f6640756b4", size = 763834, upload-time = "2025-11-12T08:59:11.519Z" }
115
+ wheels = [
116
+ { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" },
117
+ ]
118
+
119
  [[package]]
120
  name = "annotated-doc"
121
  version = "0.0.4"
 
261
  { url = "https://files.pythonhosted.org/packages/f5/37/7cd297ff571c4d86371ff024c0e008b37b59e895b28f69444a9b6f94ca1a/bcrypt-3.2.2-cp36-abi3-win_amd64.whl", hash = "sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129", size = 29581, upload-time = "2022-05-01T18:05:57.878Z" },
262
  ]
263
 
264
+ [[package]]
265
+ name = "blinker"
266
+ version = "1.9.0"
267
+ source = { registry = "https://pypi.org/simple" }
268
+ sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
269
+ wheels = [
270
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
271
+ ]
272
+
273
+ [[package]]
274
+ name = "cachetools"
275
+ version = "6.2.6"
276
+ source = { registry = "https://pypi.org/simple" }
277
+ sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" }
278
+ wheels = [
279
+ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
280
+ ]
281
+
282
  [[package]]
283
  name = "certifi"
284
  version = "2025.11.12"
 
712
  { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
713
  ]
714
 
715
+ [[package]]
716
+ name = "gitdb"
717
+ version = "4.0.12"
718
+ source = { registry = "https://pypi.org/simple" }
719
+ dependencies = [
720
+ { name = "smmap" },
721
+ ]
722
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
723
+ wheels = [
724
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
725
+ ]
726
+
727
+ [[package]]
728
+ name = "gitpython"
729
+ version = "3.1.46"
730
+ source = { registry = "https://pypi.org/simple" }
731
+ dependencies = [
732
+ { name = "gitdb" },
733
+ ]
734
+ sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
735
+ wheels = [
736
+ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
737
+ ]
738
+
739
  [[package]]
740
  name = "googleapis-common-protos"
741
  version = "1.72.0"
 
961
  { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
962
  ]
963
 
964
+ [[package]]
965
+ name = "jsonschema"
966
+ version = "4.26.0"
967
+ source = { registry = "https://pypi.org/simple" }
968
+ dependencies = [
969
+ { name = "attrs" },
970
+ { name = "jsonschema-specifications" },
971
+ { name = "referencing" },
972
+ { name = "rpds-py" },
973
+ ]
974
+ sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
975
+ wheels = [
976
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
977
+ ]
978
+
979
+ [[package]]
980
+ name = "jsonschema-specifications"
981
+ version = "2025.9.1"
982
+ source = { registry = "https://pypi.org/simple" }
983
+ dependencies = [
984
+ { name = "referencing" },
985
+ ]
986
+ sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
987
+ wheels = [
988
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
989
+ ]
990
+
991
  [[package]]
992
  name = "langchain"
993
  version = "1.2.0"
 
1311
  { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
1312
  ]
1313
 
1314
+ [[package]]
1315
+ name = "narwhals"
1316
+ version = "2.17.0"
1317
+ source = { registry = "https://pypi.org/simple" }
1318
+ sdist = { url = "https://files.pythonhosted.org/packages/75/59/81d0f4cad21484083466f278e6b392addd9f4205b48d45b5c8771670ebf8/narwhals-2.17.0.tar.gz", hash = "sha256:ebd5bc95bcfa2f8e89a8ac09e2765a63055162837208e67b42d6eeb6651d5e67", size = 620306, upload-time = "2026-02-23T09:44:34.142Z" }
1319
+ wheels = [
1320
+ { url = "https://files.pythonhosted.org/packages/4b/27/20770bd6bf8fbe1e16f848ba21da9df061f38d2e6483952c29d2bb5d1d8b/narwhals-2.17.0-py3-none-any.whl", hash = "sha256:2ac5307b7c2b275a7d66eeda906b8605e3d7a760951e188dcfff86e8ebe083dd", size = 444897, upload-time = "2026-02-23T09:44:32.006Z" },
1321
+ ]
1322
+
1323
  [[package]]
1324
  name = "numpy"
1325
  version = "2.4.0"
 
1760
  { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" },
1761
  ]
1762
 
1763
+ [[package]]
1764
+ name = "pyarrow"
1765
+ version = "23.0.1"
1766
+ source = { registry = "https://pypi.org/simple" }
1767
+ sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" }
1768
+ wheels = [
1769
+ { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" },
1770
+ { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" },
1771
+ { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" },
1772
+ { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" },
1773
+ { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" },
1774
+ { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" },
1775
+ { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" },
1776
+ { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" },
1777
+ { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" },
1778
+ { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" },
1779
+ { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" },
1780
+ { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" },
1781
+ { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" },
1782
+ { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" },
1783
+ { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" },
1784
+ { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" },
1785
+ { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" },
1786
+ { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" },
1787
+ { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" },
1788
+ { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" },
1789
+ { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" },
1790
+ { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" },
1791
+ { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" },
1792
+ { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" },
1793
+ { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" },
1794
+ { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" },
1795
+ { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" },
1796
+ { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" },
1797
+ ]
1798
+
1799
  [[package]]
1800
  name = "pyasn1"
1801
  version = "0.6.1"
 
1914
  { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
1915
  ]
1916
 
1917
+ [[package]]
1918
+ name = "pydeck"
1919
+ version = "0.9.1"
1920
+ source = { registry = "https://pypi.org/simple" }
1921
+ dependencies = [
1922
+ { name = "jinja2" },
1923
+ { name = "numpy" },
1924
+ ]
1925
+ sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" }
1926
+ wheels = [
1927
+ { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" },
1928
+ ]
1929
+
1930
  [[package]]
1931
  name = "pygments"
1932
  version = "2.19.2"
 
2090
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
2091
  ]
2092
 
2093
+ [[package]]
2094
+ name = "referencing"
2095
+ version = "0.37.0"
2096
+ source = { registry = "https://pypi.org/simple" }
2097
+ dependencies = [
2098
+ { name = "attrs" },
2099
+ { name = "rpds-py" },
2100
+ ]
2101
+ sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
2102
+ wheels = [
2103
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
2104
+ ]
2105
+
2106
  [[package]]
2107
  name = "regex"
2108
  version = "2025.11.3"
 
2274
  { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
2275
  ]
2276
 
2277
+ [[package]]
2278
+ name = "rpds-py"
2279
+ version = "0.30.0"
2280
+ source = { registry = "https://pypi.org/simple" }
2281
+ sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
2282
+ wheels = [
2283
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
2284
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
2285
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
2286
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
2287
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
2288
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
2289
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
2290
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
2291
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
2292
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
2293
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
2294
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
2295
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
2296
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
2297
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
2298
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
2299
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
2300
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
2301
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
2302
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
2303
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
2304
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
2305
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
2306
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
2307
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
2308
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
2309
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
2310
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
2311
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
2312
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
2313
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
2314
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
2315
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
2316
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
2317
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
2318
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
2319
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
2320
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
2321
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
2322
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
2323
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
2324
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
2325
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
2326
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
2327
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
2328
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
2329
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
2330
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
2331
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
2332
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
2333
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
2334
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
2335
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
2336
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
2337
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
2338
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
2339
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
2340
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
2341
+ ]
2342
+
2343
  [[package]]
2344
  name = "rsa"
2345
  version = "4.9.1"
 
2395
  { name = "uvicorn" },
2396
  ]
2397
 
2398
+ [package.dev-dependencies]
2399
+ dev = [
2400
+ { name = "streamlit" },
2401
+ ]
2402
+
2403
  [package.metadata]
2404
  requires-dist = [
2405
  { name = "aiohttp", specifier = ">=3.13.2" },
 
2427
  { name = "uvicorn", specifier = ">=0.40.0" },
2428
  ]
2429
 
2430
+ [package.metadata.requires-dev]
2431
+ dev = [{ name = "streamlit", specifier = ">=1.54.0" }]
2432
+
2433
  [[package]]
2434
  name = "shellingham"
2435
  version = "1.5.4"
 
2448
  { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
2449
  ]
2450
 
2451
+ [[package]]
2452
+ name = "smmap"
2453
+ version = "5.0.2"
2454
+ source = { registry = "https://pypi.org/simple" }
2455
+ sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" }
2456
+ wheels = [
2457
+ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
2458
+ ]
2459
+
2460
  [[package]]
2461
  name = "sniffio"
2462
  version = "1.3.1"
 
2507
  { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
2508
  ]
2509
 
2510
+ [[package]]
2511
+ name = "streamlit"
2512
+ version = "1.54.0"
2513
+ source = { registry = "https://pypi.org/simple" }
2514
+ dependencies = [
2515
+ { name = "altair" },
2516
+ { name = "blinker" },
2517
+ { name = "cachetools" },
2518
+ { name = "click" },
2519
+ { name = "gitpython" },
2520
+ { name = "numpy" },
2521
+ { name = "packaging" },
2522
+ { name = "pandas" },
2523
+ { name = "pillow" },
2524
+ { name = "protobuf" },
2525
+ { name = "pyarrow" },
2526
+ { name = "pydeck" },
2527
+ { name = "requests" },
2528
+ { name = "tenacity" },
2529
+ { name = "toml" },
2530
+ { name = "tornado" },
2531
+ { name = "typing-extensions" },
2532
+ { name = "watchdog", marker = "sys_platform != 'darwin'" },
2533
+ ]
2534
+ sdist = { url = "https://files.pythonhosted.org/packages/be/66/d887ee80ea85f035baee607c60af024994e17ae9b921277fca9675e76ecf/streamlit-1.54.0.tar.gz", hash = "sha256:09965e6ae7eb0357091725de1ce2a3f7e4be155c2464c505c40a3da77ab69dd8", size = 8662292, upload-time = "2026-02-04T16:37:54.734Z" }
2535
+ wheels = [
2536
+ { url = "https://files.pythonhosted.org/packages/48/1d/40de1819374b4f0507411a60f4d2de0d620a9b10c817de5925799132b6c9/streamlit-1.54.0-py3-none-any.whl", hash = "sha256:a7b67d6293a9f5f6b4d4c7acdbc4980d7d9f049e78e404125022ecb1712f79fc", size = 9119730, upload-time = "2026-02-04T16:37:52.199Z" },
2537
+ ]
2538
+
2539
  [[package]]
2540
  name = "tenacity"
2541
  version = "9.1.2"
 
2585
  { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
2586
  ]
2587
 
2588
+ [[package]]
2589
+ name = "toml"
2590
+ version = "0.10.2"
2591
+ source = { registry = "https://pypi.org/simple" }
2592
+ sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
2593
+ wheels = [
2594
+ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
2595
+ ]
2596
+
2597
+ [[package]]
2598
+ name = "tornado"
2599
+ version = "6.5.4"
2600
+ source = { registry = "https://pypi.org/simple" }
2601
+ sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
2602
+ wheels = [
2603
+ { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
2604
+ { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
2605
+ { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
2606
+ { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
2607
+ { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
2608
+ { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
2609
+ { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
2610
+ { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
2611
+ { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
2612
+ { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
2613
+ { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
2614
+ ]
2615
+
2616
  [[package]]
2617
  name = "tqdm"
2618
  version = "4.67.1"
 
2751
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
2752
  ]
2753
 
2754
+ [[package]]
2755
+ name = "watchdog"
2756
+ version = "6.0.0"
2757
+ source = { registry = "https://pypi.org/simple" }
2758
+ sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
2759
+ wheels = [
2760
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
2761
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
2762
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
2763
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
2764
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
2765
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
2766
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
2767
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
2768
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
2769
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
2770
+ ]
2771
+
2772
  [[package]]
2773
  name = "watchfiles"
2774
  version = "1.1.1"