codex-agent-framework 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_agent/__init__.py +38 -0
- codex_agent/__main__.py +18 -0
- codex_agent/agent.py +1179 -0
- codex_agent/ai.py +494 -0
- codex_agent/builtin_commands.py +136 -0
- codex_agent/builtin_providers.py +42 -0
- codex_agent/builtin_tools.py +418 -0
- codex_agent/chat.py +139 -0
- codex_agent/command.py +19 -0
- codex_agent/event.py +136 -0
- codex_agent/get_text/__init__.py +20 -0
- codex_agent/get_text/default_gitignore +110 -0
- codex_agent/get_text/get_text.py +1240 -0
- codex_agent/get_text/simpler_get_text.py +184 -0
- codex_agent/get_webdriver.py +44 -0
- codex_agent/image.py +304 -0
- codex_agent/latex.py +165 -0
- codex_agent/memory.py +318 -0
- codex_agent/message.py +423 -0
- codex_agent/prompts/image_generation_system_prompt.txt +13 -0
- codex_agent/prompts/system_prompt.txt +88 -0
- codex_agent/provider.py +19 -0
- codex_agent/stream_utils.py +645 -0
- codex_agent/tool.py +235 -0
- codex_agent/utils.py +374 -0
- codex_agent/voice.py +353 -0
- codex_agent/worker.py +190 -0
- codex_agent_framework-0.1.1.dist-info/METADATA +361 -0
- codex_agent_framework-0.1.1.dist-info/RECORD +33 -0
- codex_agent_framework-0.1.1.dist-info/WHEEL +5 -0
- codex_agent_framework-0.1.1.dist-info/entry_points.txt +2 -0
- codex_agent_framework-0.1.1.dist-info/licenses/LICENSE +21 -0
- codex_agent_framework-0.1.1.dist-info/top_level.txt +1 -0
codex_agent/tool.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from .message import DeveloperMessage
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from modict import modict
|
|
4
|
+
from typing import get_args, get_origin, Literal, Union
|
|
5
|
+
import json
|
|
6
|
+
import inspect
|
|
7
|
+
|
|
8
|
+
TOOL_MARKER = "__codex_agent_tool__"
|
|
9
|
+
_current_agent = ContextVar("codex_agent_current_agent", default=None)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def tool(func=None, **options):
|
|
13
|
+
"""Mark a function as an agent tool for automatic discovery."""
|
|
14
|
+
if func is None:
|
|
15
|
+
def decorator(f):
|
|
16
|
+
return tool(f, **options)
|
|
17
|
+
return decorator
|
|
18
|
+
|
|
19
|
+
setattr(func, TOOL_MARKER, modict(options))
|
|
20
|
+
return func
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tool_options(func):
|
|
24
|
+
return getattr(func, TOOL_MARKER, None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_agent():
|
|
28
|
+
agent = _current_agent.get()
|
|
29
|
+
if agent is None:
|
|
30
|
+
raise RuntimeError("No current agent is available outside an agent tool call.")
|
|
31
|
+
return agent
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_current_agent(agent):
|
|
35
|
+
return _current_agent.set(agent)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def reset_current_agent(token):
|
|
39
|
+
_current_agent.reset(token)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ServerTool(modict):
|
|
43
|
+
"""Responses API server-side tool without a local Python handler."""
|
|
44
|
+
|
|
45
|
+
type = None
|
|
46
|
+
|
|
47
|
+
def to_response_tool(self):
|
|
48
|
+
return {key: value for key, value in self.items() if value is not None}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WebSearchTool(ServerTool):
|
|
52
|
+
type = "web_search"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ImageGenerationTool(ServerTool):
|
|
56
|
+
type = "image_generation"
|
|
57
|
+
output_format = "png"
|
|
58
|
+
size = None
|
|
59
|
+
quality = None
|
|
60
|
+
background = None
|
|
61
|
+
moderation = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Tool:
|
|
65
|
+
"""Local tool callable exposed to the model."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
obj,
|
|
70
|
+
name=None,
|
|
71
|
+
description=None,
|
|
72
|
+
parameters=None,
|
|
73
|
+
required=None,
|
|
74
|
+
type=None,
|
|
75
|
+
mode=None,
|
|
76
|
+
format=None,
|
|
77
|
+
):
|
|
78
|
+
self.obj=obj
|
|
79
|
+
self.name=name or getattr(obj,'__name__',None) or obj.__class__.__name__
|
|
80
|
+
self.schema:modict=self.infer_schema()
|
|
81
|
+
self.schema.update(self.parse_doc())
|
|
82
|
+
if description:
|
|
83
|
+
self.schema.description=description
|
|
84
|
+
if parameters:
|
|
85
|
+
self.schema.parameters=parameters
|
|
86
|
+
if required:
|
|
87
|
+
self.schema.required=required
|
|
88
|
+
if format:
|
|
89
|
+
self.schema.format=format
|
|
90
|
+
self.mode = mode or self.schema.pop('mode', None) or 'api'
|
|
91
|
+
self.type = type or self.schema.pop('type',None) or 'function'
|
|
92
|
+
self.format = self.schema.pop('format', None)
|
|
93
|
+
|
|
94
|
+
def __getattr__(self,name):
|
|
95
|
+
return getattr(self.obj,name)
|
|
96
|
+
|
|
97
|
+
def __call__(self,*args,**kwargs):
|
|
98
|
+
return self.obj(*args,**kwargs)
|
|
99
|
+
|
|
100
|
+
def infer_schema(self):
|
|
101
|
+
try:
|
|
102
|
+
signature = inspect.signature(self.obj)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return modict()
|
|
105
|
+
|
|
106
|
+
parameters = modict()
|
|
107
|
+
required = []
|
|
108
|
+
for name, parameter in signature.parameters.items():
|
|
109
|
+
if name in ("self", "cls"):
|
|
110
|
+
continue
|
|
111
|
+
if parameter.kind in (
|
|
112
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
113
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
114
|
+
):
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
schema = self.annotation_to_schema(parameter.annotation)
|
|
118
|
+
if parameter.default is inspect.Parameter.empty:
|
|
119
|
+
required.append(name)
|
|
120
|
+
else:
|
|
121
|
+
schema["default"] = parameter.default
|
|
122
|
+
parameters[name] = schema
|
|
123
|
+
|
|
124
|
+
return modict(parameters=parameters, required=required)
|
|
125
|
+
|
|
126
|
+
def annotation_to_schema(self, annotation):
|
|
127
|
+
if annotation is inspect.Parameter.empty:
|
|
128
|
+
return {"type": "string"}
|
|
129
|
+
|
|
130
|
+
origin = get_origin(annotation)
|
|
131
|
+
args = get_args(annotation)
|
|
132
|
+
|
|
133
|
+
if origin is Literal:
|
|
134
|
+
values = list(args)
|
|
135
|
+
schema = {"enum": values}
|
|
136
|
+
if values:
|
|
137
|
+
schema["type"] = self.json_type(type(values[0]))
|
|
138
|
+
return schema
|
|
139
|
+
|
|
140
|
+
if origin in (list, tuple, set):
|
|
141
|
+
item_type = args[0] if args else str
|
|
142
|
+
return {
|
|
143
|
+
"type": "array",
|
|
144
|
+
"items": self.annotation_to_schema(item_type),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if origin is dict:
|
|
148
|
+
return {"type": "object"}
|
|
149
|
+
|
|
150
|
+
if origin is Union:
|
|
151
|
+
non_null_args = [arg for arg in args if arg is not type(None)]
|
|
152
|
+
if len(non_null_args) == 1:
|
|
153
|
+
schema = self.annotation_to_schema(non_null_args[0])
|
|
154
|
+
schema["nullable"] = True
|
|
155
|
+
return schema
|
|
156
|
+
return {"anyOf": [self.annotation_to_schema(arg) for arg in non_null_args]}
|
|
157
|
+
|
|
158
|
+
return {"type": self.json_type(annotation)}
|
|
159
|
+
|
|
160
|
+
def json_type(self, python_type):
|
|
161
|
+
return {
|
|
162
|
+
str: "string",
|
|
163
|
+
int: "integer",
|
|
164
|
+
float: "number",
|
|
165
|
+
bool: "boolean",
|
|
166
|
+
dict: "object",
|
|
167
|
+
list: "array",
|
|
168
|
+
tuple: "array",
|
|
169
|
+
set: "array",
|
|
170
|
+
}.get(python_type, "string")
|
|
171
|
+
|
|
172
|
+
def parse_doc(self):
|
|
173
|
+
"""Parses the YAML docstring of the tool's function to extract metadata.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
modict: Schema containing description, parameters, and required fields.
|
|
177
|
+
"""
|
|
178
|
+
import yaml
|
|
179
|
+
from textwrap import dedent
|
|
180
|
+
|
|
181
|
+
doc = self.obj.__doc__
|
|
182
|
+
if not doc:
|
|
183
|
+
return modict()
|
|
184
|
+
|
|
185
|
+
# Nettoie l'indentation avec dedent, puis strip les quotes et espaces
|
|
186
|
+
doc_str = dedent(doc).strip().strip('"""').strip("'''").strip()
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
schema = yaml.safe_load(doc_str)
|
|
190
|
+
if not isinstance(schema, dict):
|
|
191
|
+
return modict(description=doc_str)
|
|
192
|
+
except Exception:
|
|
193
|
+
return modict(description=doc_str)
|
|
194
|
+
|
|
195
|
+
return modict(schema)
|
|
196
|
+
|
|
197
|
+
def to_response_tool(self):
|
|
198
|
+
"""Converts the Tool object into a Responses API tool schema."""
|
|
199
|
+
if self.type == "custom":
|
|
200
|
+
tool = dict(
|
|
201
|
+
type="custom",
|
|
202
|
+
name=self.name,
|
|
203
|
+
description=self.schema.get('description','No description provided'),
|
|
204
|
+
)
|
|
205
|
+
if self.format:
|
|
206
|
+
tool["format"] = dict(self.format)
|
|
207
|
+
return tool
|
|
208
|
+
|
|
209
|
+
properties=dict()
|
|
210
|
+
for name,param in self.schema.get('parameters',{}).items():
|
|
211
|
+
if isinstance(param,dict):
|
|
212
|
+
properties[name]=dict(param)
|
|
213
|
+
elif isinstance(param,str):
|
|
214
|
+
properties[name]=param
|
|
215
|
+
|
|
216
|
+
tool=dict(
|
|
217
|
+
type="function",
|
|
218
|
+
name=self.name,
|
|
219
|
+
description=self.schema.get('description','No description provided'),
|
|
220
|
+
parameters=dict(
|
|
221
|
+
type="object",
|
|
222
|
+
properties=properties,
|
|
223
|
+
required=self.schema.get('required',[])
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
return tool
|
|
227
|
+
|
|
228
|
+
def to_developer_message(self):
|
|
229
|
+
"""Converts the Tool object into a developer message for parsed tool guidance.
|
|
230
|
+
For tools that don't use the usual API function call pipeline, this can be used to provide the AI with the tool's schema in a more flexible way.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Message: Developer message containing the tool's schema.
|
|
234
|
+
"""
|
|
235
|
+
return DeveloperMessage(content=f"Tool: {self.name}\nSchema:\n{json.dumps(self.to_response_tool(), indent=2, ensure_ascii=False)}")
|
codex_agent/utils.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import tiktoken
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from textwrap import dedent
|
|
7
|
+
import random, string
|
|
8
|
+
import json
|
|
9
|
+
import regex
|
|
10
|
+
from threading import Thread as ThreadBase
|
|
11
|
+
import sys
|
|
12
|
+
import inspect
|
|
13
|
+
from importlib import import_module
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
|
|
16
|
+
RUNTIME_DIR=os.path.expanduser(os.environ.get("AGENT_RUNTIME_DIR", "~/.agent_runtime"))
|
|
17
|
+
|
|
18
|
+
def runtime_join(*args):
|
|
19
|
+
return os.path.join(RUNTIME_DIR,*args)
|
|
20
|
+
|
|
21
|
+
def ensure_runtime_dir():
|
|
22
|
+
if not os.path.isdir(RUNTIME_DIR):
|
|
23
|
+
os.makedirs(RUNTIME_DIR,exist_ok=True)
|
|
24
|
+
|
|
25
|
+
def set_root_path(file=None):
|
|
26
|
+
file=file or __file__
|
|
27
|
+
os.environ['AGENT_ROOT_PATH']=os.path.dirname(os.path.abspath(file))
|
|
28
|
+
|
|
29
|
+
def root_join(*args):
|
|
30
|
+
return os.path.join(os.getenv('AGENT_ROOT_PATH'),*args)
|
|
31
|
+
|
|
32
|
+
def text_content(file):
|
|
33
|
+
if os.path.isfile(file):
|
|
34
|
+
with open(file,encoding="utf-8") as f:
|
|
35
|
+
return f.read()
|
|
36
|
+
else:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def add_line_numbers(text: str) -> str:
|
|
40
|
+
lines = text.splitlines()
|
|
41
|
+
n = len(lines)
|
|
42
|
+
width = len(str(n))
|
|
43
|
+
|
|
44
|
+
lines_num = [f"{i:0{width}d}|{line}"
|
|
45
|
+
for i, line in enumerate(lines, start=1)]
|
|
46
|
+
return "\n".join(lines_num)
|
|
47
|
+
|
|
48
|
+
def short_id(length=8):
|
|
49
|
+
return "".join(random.choices(string.ascii_letters,k=length))
|
|
50
|
+
|
|
51
|
+
def session_id():
|
|
52
|
+
"""Generate a unique, lexicographically sortable session ID."""
|
|
53
|
+
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
54
|
+
|
|
55
|
+
tokenizer = tiktoken.get_encoding("cl100k_base")
|
|
56
|
+
|
|
57
|
+
def tokenize(string):
|
|
58
|
+
int_tokens = tokenizer.encode(string)
|
|
59
|
+
str_tokens = [tokenizer.decode([int_token]) for int_token in int_tokens]
|
|
60
|
+
return str_tokens
|
|
61
|
+
|
|
62
|
+
def utf8_safe_tokenize(s):
|
|
63
|
+
return regex.findall(r'\X', s)
|
|
64
|
+
|
|
65
|
+
def token_count(string):
|
|
66
|
+
return len(tokenizer.encode(string))
|
|
67
|
+
|
|
68
|
+
def sort(messages):
|
|
69
|
+
return sorted(messages, key=lambda msg: msg.timestamp)
|
|
70
|
+
|
|
71
|
+
def snake_case_type(cls):
|
|
72
|
+
name = cls.__name__
|
|
73
|
+
chars = []
|
|
74
|
+
for i, char in enumerate(name):
|
|
75
|
+
if char.isupper() and i > 0:
|
|
76
|
+
chars.append('_')
|
|
77
|
+
chars.append(char.lower())
|
|
78
|
+
return ''.join(chars)
|
|
79
|
+
|
|
80
|
+
def truncate(string, max_tokens=2000, start_line=1):
|
|
81
|
+
"""
|
|
82
|
+
Truncate a string to a maximum number of tokens using line-based chunking.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
string: The string to truncate
|
|
86
|
+
max_tokens: Maximum number of tokens to keep
|
|
87
|
+
start_line: The line number of the first line in the string (1-indexed).
|
|
88
|
+
Used for skipping content and tracking line numbers in truncation messages.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Truncated string with indication of removed content
|
|
92
|
+
"""
|
|
93
|
+
# Quick check - if already under limit, return as-is
|
|
94
|
+
tokens = tokenize(string)
|
|
95
|
+
if len(tokens) <= max_tokens:
|
|
96
|
+
return string
|
|
97
|
+
|
|
98
|
+
# line-based truncation
|
|
99
|
+
lines = string.splitlines(keepends=True)
|
|
100
|
+
|
|
101
|
+
# Skip to start_line if needed
|
|
102
|
+
if start_line > 1:
|
|
103
|
+
if start_line > len(lines):
|
|
104
|
+
return ""
|
|
105
|
+
lines = lines[start_line - 1:]
|
|
106
|
+
|
|
107
|
+
# Calculate the actual last line number after skipping
|
|
108
|
+
remaining_lines = len(lines)
|
|
109
|
+
|
|
110
|
+
# Assemble lines up to max_tokens
|
|
111
|
+
result_lines = []
|
|
112
|
+
current_tokens = 0
|
|
113
|
+
|
|
114
|
+
for line in lines:
|
|
115
|
+
line_tokens = token_count(line)
|
|
116
|
+
if current_tokens + line_tokens <= max_tokens:
|
|
117
|
+
result_lines.append(line)
|
|
118
|
+
current_tokens += line_tokens
|
|
119
|
+
else:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
if result_lines:
|
|
123
|
+
result = ''.join(result_lines)
|
|
124
|
+
num_kept = len(result_lines)
|
|
125
|
+
|
|
126
|
+
# Only show truncation message if we didn't include all lines
|
|
127
|
+
if num_kept < remaining_lines:
|
|
128
|
+
first_truncated = start_line + num_kept
|
|
129
|
+
last_line = start_line + remaining_lines - 1
|
|
130
|
+
|
|
131
|
+
if not result.endswith('\n'):
|
|
132
|
+
result += '\n'
|
|
133
|
+
result += f"\n...\n\n[Lines {first_truncated}-{last_line} truncated]\n"
|
|
134
|
+
|
|
135
|
+
return result
|
|
136
|
+
else:
|
|
137
|
+
return f"[Lines {start_line}-{start_line + remaining_lines - 1} truncated - first lineexceeds token limit]\n"
|
|
138
|
+
|
|
139
|
+
def pack_msgs(messages):
|
|
140
|
+
text = ''
|
|
141
|
+
for message in messages:
|
|
142
|
+
text += message.name + ':\n'
|
|
143
|
+
text += message.content.strip() + '\n\n'
|
|
144
|
+
return text
|
|
145
|
+
|
|
146
|
+
def msg_token_count(msg):
|
|
147
|
+
count=0
|
|
148
|
+
if msg.type=='image_message':
|
|
149
|
+
count+=1000
|
|
150
|
+
count+=token_count(str(msg.get('description') or ''))
|
|
151
|
+
count+=token_count(str(msg.get('image_name') or msg.get('content') or ''))
|
|
152
|
+
else:
|
|
153
|
+
count+=token_count(str(msg.get('content') or ''))
|
|
154
|
+
|
|
155
|
+
for tool_call in msg.get('tool_calls') or []:
|
|
156
|
+
count+=token_count(json.dumps(tool_call.get('arguments','')))
|
|
157
|
+
return count
|
|
158
|
+
|
|
159
|
+
def total_tokens(messages):
|
|
160
|
+
return sum(msg_token_count(msg) for msg in messages)
|
|
161
|
+
|
|
162
|
+
def split_history_turns(messages):
|
|
163
|
+
"""Split a message history into complete turns and a trailing remainder.
|
|
164
|
+
|
|
165
|
+
Any message can start a turn. The split is driven only by end markers:
|
|
166
|
+
a final assistant message, or an interruption marker. Assistant messages
|
|
167
|
+
with tool calls are intermediate loop messages, not final answers.
|
|
168
|
+
"""
|
|
169
|
+
turns = []
|
|
170
|
+
current = []
|
|
171
|
+
|
|
172
|
+
def msg_type(msg):
|
|
173
|
+
return msg.get("type") if isinstance(msg, dict) else getattr(msg, "type", None)
|
|
174
|
+
|
|
175
|
+
def is_final_assistant(msg):
|
|
176
|
+
if msg_type(msg) != "assistant_message":
|
|
177
|
+
return False
|
|
178
|
+
tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None)
|
|
179
|
+
return not tool_calls
|
|
180
|
+
|
|
181
|
+
def is_interruption_marker(msg):
|
|
182
|
+
if msg_type(msg) != "developer_message":
|
|
183
|
+
return False
|
|
184
|
+
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "")
|
|
185
|
+
return "interrupted by the user" in str(content).lower()
|
|
186
|
+
|
|
187
|
+
for msg in messages:
|
|
188
|
+
current.append(msg)
|
|
189
|
+
if is_final_assistant(msg) or is_interruption_marker(msg):
|
|
190
|
+
turns.append(current)
|
|
191
|
+
current = []
|
|
192
|
+
|
|
193
|
+
return turns, current
|
|
194
|
+
|
|
195
|
+
def find_pattern(text, pattern=None):
|
|
196
|
+
"""
|
|
197
|
+
Renvoie une liste de tuples contenant TOUS les groupes capturés par le pattern.
|
|
198
|
+
|
|
199
|
+
- Si le pattern contient 1 groupe -> tuple de 1 élément
|
|
200
|
+
- Si le pattern contient n groupes -> tuple de n éléments
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
pattern = pattern or r'```python(.*?)```'
|
|
204
|
+
iterator = re.finditer(pattern, text, re.DOTALL)
|
|
205
|
+
|
|
206
|
+
return [
|
|
207
|
+
tuple(match.groups()) # groups() renvoie *tous* les groupes capturés
|
|
208
|
+
for match in iterator
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
def format(string, context=None):
|
|
212
|
+
# Si aucun contexte n'est fourni, utiliser un dictionnaire vide
|
|
213
|
+
if context is None:
|
|
214
|
+
context = {}
|
|
215
|
+
# Trouver les expressions entre <<...>>
|
|
216
|
+
def replace_expr(match):
|
|
217
|
+
expr = match.group(1)
|
|
218
|
+
try:
|
|
219
|
+
# Évaluer l'expression dans le contexte donné et la convertir en chaîne
|
|
220
|
+
return str(eval(expr, context))
|
|
221
|
+
except Exception as e:
|
|
222
|
+
# print(f"could not evaluate expr: {expr}\n Exception:\n {str(e)}")
|
|
223
|
+
# En cas d'erreur, retourner l'expression non évaluée
|
|
224
|
+
return '<<' + expr + '>>'
|
|
225
|
+
# Remplacer chaque expression par son évaluation
|
|
226
|
+
return re.sub(r'<<(.*?)>>', replace_expr, string)
|
|
227
|
+
|
|
228
|
+
def timestamp(digits=3):
|
|
229
|
+
base = datetime.now().strftime("%Y%m%dT%H%M%S.%f")
|
|
230
|
+
if digits == 0:
|
|
231
|
+
return base[:15] # Jusqu'à la seconde uniquement
|
|
232
|
+
return base[:15 + 1 + digits]
|
|
233
|
+
|
|
234
|
+
def guess_extension_from_bytes(data):
|
|
235
|
+
"""
|
|
236
|
+
Guess file extension from byte content using filetype library.
|
|
237
|
+
Returns None if unable to determine the file type.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
data: bytes or BytesIO object containing file data
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
str: File extension with leading dot (e.g., '.png') or None if unable to determine
|
|
244
|
+
"""
|
|
245
|
+
import filetype
|
|
246
|
+
from io import BytesIO
|
|
247
|
+
|
|
248
|
+
# Ensure we have bytes
|
|
249
|
+
if isinstance(data, BytesIO):
|
|
250
|
+
data = data.getvalue()
|
|
251
|
+
elif hasattr(data, 'read'):
|
|
252
|
+
data = data.read()
|
|
253
|
+
|
|
254
|
+
if not data or len(data) == 0:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
kind = filetype.guess(data)
|
|
258
|
+
if kind is not None:
|
|
259
|
+
return f'.{kind.extension}'
|
|
260
|
+
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
def _uploaded_filename(source, data, name=None):
|
|
264
|
+
if name is None:
|
|
265
|
+
if isinstance(source, str):
|
|
266
|
+
name = os.path.basename(source)
|
|
267
|
+
elif hasattr(source, 'name') and source.name:
|
|
268
|
+
name = source.name
|
|
269
|
+
else:
|
|
270
|
+
name = f"uploaded_file_{short_id()}"
|
|
271
|
+
|
|
272
|
+
_, name_ext = os.path.splitext(name)
|
|
273
|
+
if name_ext:
|
|
274
|
+
extension = name_ext
|
|
275
|
+
elif isinstance(source, str):
|
|
276
|
+
extension = os.path.splitext(source)[1]
|
|
277
|
+
else:
|
|
278
|
+
extension = guess_extension_from_bytes(data) or ''
|
|
279
|
+
|
|
280
|
+
return os.path.splitext(name)[0] + extension
|
|
281
|
+
|
|
282
|
+
def upload_file_to_folder(source, folder, name=None):
|
|
283
|
+
"""Copy or write a file-like source into folder and return its absolute path."""
|
|
284
|
+
if source is None:
|
|
285
|
+
raise ValueError("A file source must be provided")
|
|
286
|
+
|
|
287
|
+
os.makedirs(folder, exist_ok=True)
|
|
288
|
+
|
|
289
|
+
if isinstance(source, str):
|
|
290
|
+
if not os.path.exists(source):
|
|
291
|
+
raise ValueError(f"File not found: {source}")
|
|
292
|
+
data = None
|
|
293
|
+
filename = _uploaded_filename(source, data, name=name)
|
|
294
|
+
dest_path = os.path.join(folder, filename)
|
|
295
|
+
shutil.copy2(source, dest_path)
|
|
296
|
+
elif isinstance(source, BytesIO):
|
|
297
|
+
data = source.getvalue()
|
|
298
|
+
filename = _uploaded_filename(source, data, name=name)
|
|
299
|
+
dest_path = os.path.join(folder, filename)
|
|
300
|
+
with open(dest_path, 'wb') as f:
|
|
301
|
+
f.write(data)
|
|
302
|
+
elif isinstance(source, bytes):
|
|
303
|
+
data = source
|
|
304
|
+
filename = _uploaded_filename(source, data, name=name)
|
|
305
|
+
dest_path = os.path.join(folder, filename)
|
|
306
|
+
with open(dest_path, 'wb') as f:
|
|
307
|
+
f.write(data)
|
|
308
|
+
else:
|
|
309
|
+
raise TypeError(f"Unsupported source type: {type(source)}. Expected str, BytesIO, or bytes.")
|
|
310
|
+
|
|
311
|
+
return os.path.abspath(dest_path)
|
|
312
|
+
|
|
313
|
+
class Logger:
|
|
314
|
+
|
|
315
|
+
def __init__(self,file=None):
|
|
316
|
+
self.file=file or root_join('logs',"log.txt")
|
|
317
|
+
open(self.file,'w', encoding='utf-8').close()
|
|
318
|
+
|
|
319
|
+
def log(self,message):
|
|
320
|
+
with open(self.file,'a',encoding="utf-8") as f:
|
|
321
|
+
f.write(str(message)+'\n')
|
|
322
|
+
|
|
323
|
+
def log_to_file(self,content,file=None):
|
|
324
|
+
file = file or self.file
|
|
325
|
+
with open(file,'w',encoding="utf-8") as f:
|
|
326
|
+
f.write(str(content)+'\n')
|
|
327
|
+
|
|
328
|
+
def read_document_content(source, start_at_line=1, max_tokens=8000):
|
|
329
|
+
"""
|
|
330
|
+
Read and extract text content from various document formats.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
source: Document source - either a local file path or URL (http/https)
|
|
334
|
+
start_at_line: Line number to start reading from (1-indexed)
|
|
335
|
+
max_tokens: Maximum tokens for truncation (0 to disable)
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Extracted text content, possibly truncated
|
|
339
|
+
"""
|
|
340
|
+
get_text = import_module(".get_text.get_text", __package__).get_text
|
|
341
|
+
|
|
342
|
+
text=get_text(source)
|
|
343
|
+
|
|
344
|
+
# Apply truncation - truncate function handles smart chunking internally
|
|
345
|
+
if max_tokens > 0:
|
|
346
|
+
text = truncate(text, max_tokens=max_tokens, start_line=start_at_line)
|
|
347
|
+
|
|
348
|
+
return text
|
|
349
|
+
|
|
350
|
+
def is_running_in_streamlit_runtime():
|
|
351
|
+
try:
|
|
352
|
+
from streamlit.runtime.scriptrunner import get_script_run_ctx
|
|
353
|
+
except Exception:
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
return get_script_run_ctx() is not None
|
|
357
|
+
|
|
358
|
+
class ThreadPool:
|
|
359
|
+
threads = {}
|
|
360
|
+
|
|
361
|
+
@classmethod
|
|
362
|
+
def register(cls, thread):
|
|
363
|
+
cls.threads[thread.name] = thread
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def join_all(cls, timeout=None):
|
|
367
|
+
for thread in list(cls.threads.values()):
|
|
368
|
+
if thread.is_alive():
|
|
369
|
+
thread.join(timeout=timeout)
|
|
370
|
+
|
|
371
|
+
def Thread(*args,**kwargs):
|
|
372
|
+
thread = ThreadBase(*args,**kwargs)
|
|
373
|
+
ThreadPool.register(thread)
|
|
374
|
+
return thread
|