surveycore-py 0.1.0__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.
- surveycore/__init__.py +44 -0
- surveycore/_question_metas.py +59 -0
- surveycore/answers/__init__.py +5 -0
- surveycore/answers/strategies.py +23 -0
- surveycore/app/__init__.py +1 -0
- surveycore/app/config.py +28 -0
- surveycore/app/runtime_paths.py +16 -0
- surveycore/app/user_paths.py +2 -0
- surveycore/client.py +187 -0
- surveycore/config.py +166 -0
- surveycore/core/__init__.py +0 -0
- surveycore/core/ai/__init__.py +3 -0
- surveycore/core/ai/batch_runtime.py +42 -0
- surveycore/core/ai/runtime.py +76 -0
- surveycore/core/config/__init__.py +0 -0
- surveycore/core/engine/__init__.py +0 -0
- surveycore/core/engine/async_events.py +90 -0
- surveycore/core/engine/async_scheduler.py +96 -0
- surveycore/core/engine/async_status_bus.py +50 -0
- surveycore/core/engine/async_wait.py +58 -0
- surveycore/core/engine/provider_common.py +150 -0
- surveycore/core/engine/runtime_actions.py +63 -0
- surveycore/core/engine/stop_signal.py +14 -0
- surveycore/core/modes/__init__.py +1 -0
- surveycore/core/modes/duration_control.py +206 -0
- surveycore/core/persona/__init__.py +0 -0
- surveycore/core/persona/context.py +139 -0
- surveycore/core/persona/generator.py +221 -0
- surveycore/core/psychometrics/__init__.py +62 -0
- surveycore/core/psychometrics/joint_optimizer.py +624 -0
- surveycore/core/psychometrics/ordinal_options.py +188 -0
- surveycore/core/psychometrics/orientation.py +190 -0
- surveycore/core/psychometrics/psychometric.py +334 -0
- surveycore/core/psychometrics/utils.py +159 -0
- surveycore/core/questions/__init__.py +0 -0
- surveycore/core/questions/config.py +33 -0
- surveycore/core/questions/consistency.py +337 -0
- surveycore/core/questions/default_builder.py +383 -0
- surveycore/core/questions/distribution.py +148 -0
- surveycore/core/questions/meta_helpers.py +165 -0
- surveycore/core/questions/normalization.py +393 -0
- surveycore/core/questions/reliability_mode.py +38 -0
- surveycore/core/questions/schema.py +104 -0
- surveycore/core/questions/strict_ratio.py +152 -0
- surveycore/core/questions/tendency.py +352 -0
- surveycore/core/questions/text_shared.py +3 -0
- surveycore/core/questions/text_values.py +136 -0
- surveycore/core/questions/types/__init__.py +0 -0
- surveycore/core/questions/utils.py +465 -0
- surveycore/core/questions/validation.py +213 -0
- surveycore/core/reverse_fill/__init__.py +49 -0
- surveycore/core/reverse_fill/parser.py +295 -0
- surveycore/core/reverse_fill/runtime.py +43 -0
- surveycore/core/reverse_fill/schema.py +151 -0
- surveycore/core/task/__init__.py +11 -0
- surveycore/core/task/distribution_state.py +235 -0
- surveycore/core/task/progress_state.py +246 -0
- surveycore/core/task/reverse_fill_state.py +189 -0
- surveycore/core/task/task_context.py +280 -0
- surveycore/core/user_agent.py +11 -0
- surveycore/credamo/__init__.py +0 -0
- surveycore/credamo/provider/__init__.py +0 -0
- surveycore/credamo/provider/answering_builders.py +185 -0
- surveycore/credamo/provider/http_runtime.py +902 -0
- surveycore/credamo/provider/parser.py +566 -0
- surveycore/data/area_codes_2022.json +1680 -0
- surveycore/errors.py +22 -0
- surveycore/io/__init__.py +0 -0
- surveycore/io/spreadsheets/__init__.py +0 -0
- surveycore/io/spreadsheets/wjx_excel.py +149 -0
- surveycore/logging/__init__.py +0 -0
- surveycore/logging/log_utils.py +43 -0
- surveycore/models.py +134 -0
- surveycore/network/__init__.py +1 -0
- surveycore/network/http/__init__.py +63 -0
- surveycore/network/session_policy.py +25 -0
- surveycore/providers/__init__.py +0 -0
- surveycore/providers/adapter_base.py +158 -0
- surveycore/providers/answering/__init__.py +7 -0
- surveycore/providers/answering/actions.py +49 -0
- surveycore/providers/answering/option_fill.py +71 -0
- surveycore/providers/answering/recording.py +55 -0
- surveycore/providers/answering/selection.py +108 -0
- surveycore/providers/common.py +174 -0
- surveycore/providers/contracts.py +435 -0
- surveycore/providers/errors.py +40 -0
- surveycore/providers/hooks.py +66 -0
- surveycore/providers/http_logic.py +285 -0
- surveycore/providers/http_progress.py +51 -0
- surveycore/providers/match_utils.py +54 -0
- surveycore/providers/registry.py +261 -0
- surveycore/providers/runtime_state.py +107 -0
- surveycore/py.typed +0 -0
- surveycore/runtime/__init__.py +5 -0
- surveycore/runtime/scheduler.py +30 -0
- surveycore/tencent/__init__.py +0 -0
- surveycore/tencent/provider/__init__.py +0 -0
- surveycore/tencent/provider/answering_builders.py +609 -0
- surveycore/tencent/provider/answering_rules.py +61 -0
- surveycore/tencent/provider/http_runtime.py +670 -0
- surveycore/tencent/provider/parser.py +1006 -0
- surveycore/transport.py +12 -0
- surveycore/wjx/__init__.py +1 -0
- surveycore/wjx/provider/__init__.py +0 -0
- surveycore/wjx/provider/answering_builders.py +727 -0
- surveycore/wjx/provider/html_parser.py +356 -0
- surveycore/wjx/provider/html_parser_choice.py +481 -0
- surveycore/wjx/provider/html_parser_common.py +586 -0
- surveycore/wjx/provider/html_parser_matrix.py +370 -0
- surveycore/wjx/provider/html_parser_rules.py +394 -0
- surveycore/wjx/provider/http_runtime.py +586 -0
- surveycore/wjx/provider/parser.py +227 -0
- surveycore/wjx/provider/questions/__init__.py +0 -0
- surveycore/wjx/provider/questions/_multiple_limit_patterns.py +130 -0
- surveycore/wjx/provider/questions/multiple_limits.py +237 -0
- surveycore/wjx/provider/questions/multiple_rules.py +115 -0
- surveycore/wjx/provider/regexes.py +164 -0
- surveycore_py-0.1.0.dist-info/METADATA +145 -0
- surveycore_py-0.1.0.dist-info/RECORD +121 -0
- surveycore_py-0.1.0.dist-info/WHEEL +4 -0
- surveycore_py-0.1.0.dist-info/licenses/LICENSE +674 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from surveycore.core.task import ExecutionState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncRunContext:
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
state: ExecutionState,
|
|
17
|
+
stop_event: asyncio.Event,
|
|
18
|
+
pause_event: asyncio.Event,
|
|
19
|
+
status_sink: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.state = state
|
|
22
|
+
self.stop_event = stop_event
|
|
23
|
+
self.pause_event = pause_event
|
|
24
|
+
self.status_sink = status_sink
|
|
25
|
+
|
|
26
|
+
def stop_requested(self) -> bool:
|
|
27
|
+
return bool(self.stop_event.is_set())
|
|
28
|
+
|
|
29
|
+
async def wait_if_paused(self) -> None:
|
|
30
|
+
while self.pause_event.is_set() and not self.stop_event.is_set():
|
|
31
|
+
await asyncio.sleep(0.1)
|
|
32
|
+
|
|
33
|
+
def emit(self, event: dict[str, Any]) -> None:
|
|
34
|
+
sink = self.status_sink
|
|
35
|
+
if callable(sink):
|
|
36
|
+
sink(dict(event or {}))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ThreadEventProxy:
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def __init__(self, event: asyncio.Event, *, loop: asyncio.AbstractEventLoop) -> None:
|
|
43
|
+
self._event = event
|
|
44
|
+
self._loop = loop
|
|
45
|
+
|
|
46
|
+
def is_set(self) -> bool:
|
|
47
|
+
return bool(self._event.is_set())
|
|
48
|
+
|
|
49
|
+
def set(self) -> None:
|
|
50
|
+
if self._loop.is_closed():
|
|
51
|
+
return
|
|
52
|
+
self._loop.call_soon_threadsafe(self._event.set)
|
|
53
|
+
|
|
54
|
+
def clear(self) -> None:
|
|
55
|
+
if self._loop.is_closed():
|
|
56
|
+
return
|
|
57
|
+
self._loop.call_soon_threadsafe(self._event.clear)
|
|
58
|
+
|
|
59
|
+
def wait(self, timeout: Optional[float] = None) -> bool:
|
|
60
|
+
if self.is_set():
|
|
61
|
+
return True
|
|
62
|
+
try:
|
|
63
|
+
running_loop = asyncio.get_running_loop()
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
running_loop = None
|
|
66
|
+
if running_loop is self._loop:
|
|
67
|
+
raise RuntimeError("ThreadEventProxy.wait() 不能在绑定的事件循环线程里阻塞调用")
|
|
68
|
+
|
|
69
|
+
async def _wait() -> bool:
|
|
70
|
+
try:
|
|
71
|
+
if timeout is None:
|
|
72
|
+
await self._event.wait()
|
|
73
|
+
return True
|
|
74
|
+
await asyncio.wait_for(self._event.wait(), timeout=max(0.0, float(timeout)))
|
|
75
|
+
return True
|
|
76
|
+
except asyncio.TimeoutError:
|
|
77
|
+
return bool(self._event.is_set())
|
|
78
|
+
|
|
79
|
+
future = asyncio.run_coroutine_threadsafe(_wait(), self._loop)
|
|
80
|
+
return bool(future.result())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class AsyncSlotResult:
|
|
85
|
+
slot_id: int
|
|
86
|
+
completed: bool
|
|
87
|
+
error: Optional[BaseException] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["AsyncRunContext", "AsyncSlotResult", "ThreadEventProxy"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import heapq
|
|
5
|
+
import itertools
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(order=True)
|
|
13
|
+
class _ScheduledToken:
|
|
14
|
+
ready_at: float
|
|
15
|
+
order: int
|
|
16
|
+
token_id: int = field(compare=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncScheduler:
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, concurrency: int) -> None:
|
|
23
|
+
self._concurrency = max(1, int(concurrency or 1))
|
|
24
|
+
self._ready: deque[int] = deque()
|
|
25
|
+
self._delayed: list[_ScheduledToken] = []
|
|
26
|
+
self._order = itertools.count()
|
|
27
|
+
self._condition = asyncio.Condition()
|
|
28
|
+
self._closed = False
|
|
29
|
+
self._waker_task: Optional[asyncio.Task[None]] = None
|
|
30
|
+
for token_id in range(self._concurrency):
|
|
31
|
+
self._ready.append(token_id)
|
|
32
|
+
|
|
33
|
+
async def start(self) -> None:
|
|
34
|
+
if self._waker_task is None:
|
|
35
|
+
self._waker_task = asyncio.create_task(self._wake_delayed_tokens(), name="AsyncSchedulerWake")
|
|
36
|
+
|
|
37
|
+
async def acquire(self) -> Optional[int]:
|
|
38
|
+
await self.start()
|
|
39
|
+
async with self._condition:
|
|
40
|
+
while True:
|
|
41
|
+
if self._closed:
|
|
42
|
+
return None
|
|
43
|
+
if self._ready:
|
|
44
|
+
return self._ready.popleft()
|
|
45
|
+
await self._condition.wait()
|
|
46
|
+
|
|
47
|
+
async def release(self, token_id: int, *, requeue: bool, delay_seconds: float = 0.0) -> None:
|
|
48
|
+
if not requeue:
|
|
49
|
+
return
|
|
50
|
+
delay = max(0.0, float(delay_seconds or 0.0))
|
|
51
|
+
async with self._condition:
|
|
52
|
+
if self._closed:
|
|
53
|
+
return
|
|
54
|
+
if delay <= 0:
|
|
55
|
+
self._ready.append(int(token_id))
|
|
56
|
+
self._condition.notify_all()
|
|
57
|
+
return
|
|
58
|
+
heapq.heappush(
|
|
59
|
+
self._delayed,
|
|
60
|
+
_ScheduledToken(time.monotonic() + delay, next(self._order), int(token_id)),
|
|
61
|
+
)
|
|
62
|
+
self._condition.notify_all()
|
|
63
|
+
|
|
64
|
+
async def _wake_delayed_tokens(self) -> None:
|
|
65
|
+
while True:
|
|
66
|
+
async with self._condition:
|
|
67
|
+
if self._closed:
|
|
68
|
+
return
|
|
69
|
+
now = time.monotonic()
|
|
70
|
+
moved = False
|
|
71
|
+
while self._delayed and self._delayed[0].ready_at <= now:
|
|
72
|
+
token = heapq.heappop(self._delayed)
|
|
73
|
+
self._ready.append(token.token_id)
|
|
74
|
+
moved = True
|
|
75
|
+
if moved:
|
|
76
|
+
self._condition.notify_all()
|
|
77
|
+
continue
|
|
78
|
+
sleep_for = 0.1
|
|
79
|
+
if self._delayed:
|
|
80
|
+
sleep_for = min(0.1, max(0.0, self._delayed[0].ready_at - now))
|
|
81
|
+
try:
|
|
82
|
+
await asyncio.sleep(sleep_for)
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
self._closed = True
|
|
88
|
+
if self._waker_task is not None:
|
|
89
|
+
self._waker_task.cancel()
|
|
90
|
+
await asyncio.gather(self._waker_task, return_exceptions=True)
|
|
91
|
+
self._waker_task = None
|
|
92
|
+
async with self._condition:
|
|
93
|
+
self._condition.notify_all()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["AsyncScheduler"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Any, Callable, DefaultDict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncStatusBus:
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
dispatcher: Optional[Callable[[Callable[[], Any]], Any]] = None,
|
|
16
|
+
throttle_seconds: float = 0.075,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._dispatcher = dispatcher
|
|
19
|
+
self._throttle_seconds = max(0.0, float(throttle_seconds or 0.0))
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
self._sequence_by_slot: DefaultDict[str, int] = defaultdict(int)
|
|
22
|
+
self._last_emit_by_slot: dict[str, float] = {}
|
|
23
|
+
|
|
24
|
+
def emit(self, event: dict[str, Any]) -> None:
|
|
25
|
+
payload = dict(event or {})
|
|
26
|
+
slot_id = str(payload.get("slot_id") or payload.get("slot_label") or "global")
|
|
27
|
+
now = time.monotonic()
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._sequence_by_slot[slot_id] += 1
|
|
30
|
+
payload["sequence"] = self._sequence_by_slot[slot_id]
|
|
31
|
+
event_type = str(payload.get("type") or "")
|
|
32
|
+
high_frequency = event_type in {"progress", "status"}
|
|
33
|
+
last_emit = self._last_emit_by_slot.get(slot_id, 0.0)
|
|
34
|
+
if high_frequency and self._throttle_seconds > 0 and (now - last_emit) < self._throttle_seconds:
|
|
35
|
+
return
|
|
36
|
+
self._last_emit_by_slot[slot_id] = now
|
|
37
|
+
|
|
38
|
+
dispatcher = self._dispatcher
|
|
39
|
+
if not callable(dispatcher):
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
def _deliver() -> None:
|
|
43
|
+
callback = payload.get("callback")
|
|
44
|
+
if callable(callback):
|
|
45
|
+
callback()
|
|
46
|
+
|
|
47
|
+
dispatcher(_deliver)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["AsyncStatusBus"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_stop_requested(stop_signal: Any) -> bool:
|
|
8
|
+
if stop_signal is None:
|
|
9
|
+
return False
|
|
10
|
+
checker = getattr(stop_signal, "is_set", None)
|
|
11
|
+
if callable(checker):
|
|
12
|
+
try:
|
|
13
|
+
return bool(checker())
|
|
14
|
+
except Exception:
|
|
15
|
+
return False
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_async_event(stop_signal: Any) -> Optional[asyncio.Event]:
|
|
20
|
+
if isinstance(stop_signal, asyncio.Event):
|
|
21
|
+
return stop_signal
|
|
22
|
+
event = getattr(stop_signal, "_event", None)
|
|
23
|
+
if isinstance(event, asyncio.Event):
|
|
24
|
+
return event
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def sleep_or_stop(stop_signal: Any, seconds: float) -> bool:
|
|
29
|
+
delay = max(0.0, float(seconds or 0.0))
|
|
30
|
+
if delay <= 0:
|
|
31
|
+
return is_stop_requested(stop_signal)
|
|
32
|
+
|
|
33
|
+
async_event = _resolve_async_event(stop_signal)
|
|
34
|
+
if async_event is not None:
|
|
35
|
+
if async_event.is_set():
|
|
36
|
+
return True
|
|
37
|
+
try:
|
|
38
|
+
await asyncio.wait_for(async_event.wait(), timeout=delay)
|
|
39
|
+
return True
|
|
40
|
+
except asyncio.TimeoutError:
|
|
41
|
+
return bool(async_event.is_set())
|
|
42
|
+
|
|
43
|
+
if stop_signal is None:
|
|
44
|
+
await asyncio.sleep(delay)
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
waiter = getattr(stop_signal, "wait", None)
|
|
48
|
+
if callable(waiter):
|
|
49
|
+
try:
|
|
50
|
+
return bool(await asyncio.to_thread(waiter, delay))
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
await asyncio.sleep(delay)
|
|
55
|
+
return is_stop_requested(stop_signal)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["is_stop_requested", "sleep_or_stop"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
6
|
+
|
|
7
|
+
from surveycore.core.persona.context import reset_context as _reset_answer_context
|
|
8
|
+
from surveycore.core.persona.generator import generate_persona, reset_persona, set_current_persona
|
|
9
|
+
from surveycore.core.psychometrics import (
|
|
10
|
+
CombinedPsychometricPlan,
|
|
11
|
+
build_dimension_psychometric_plan,
|
|
12
|
+
build_joint_psychometric_answer_plan,
|
|
13
|
+
build_psychometric_blueprint,
|
|
14
|
+
)
|
|
15
|
+
from surveycore.core.psychometrics.psychometric import normalize_target_alpha
|
|
16
|
+
from surveycore.core.questions.config import GLOBAL_RELIABILITY_DIMENSION
|
|
17
|
+
from surveycore.core.questions.consistency import reset_consistency_context
|
|
18
|
+
from surveycore.core.questions.tendency import reset_tendency
|
|
19
|
+
from surveycore.core.task import ExecutionConfig, ExecutionState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_grouped_runtime_items(
|
|
23
|
+
config: ExecutionConfig,
|
|
24
|
+
) -> Dict[str, List[Any]]:
|
|
25
|
+
grouped_items: Dict[str, List[Any]] = {}
|
|
26
|
+
for dimension, items in build_psychometric_blueprint(config).items():
|
|
27
|
+
normalized_dimension = str(dimension or "").strip()
|
|
28
|
+
if not normalized_dimension:
|
|
29
|
+
continue
|
|
30
|
+
bucket = grouped_items.setdefault(normalized_dimension, [])
|
|
31
|
+
for item in items:
|
|
32
|
+
bucket.append(item.to_runtime_item())
|
|
33
|
+
return grouped_items
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_psychometric_plan_for_run(config: ExecutionConfig) -> Optional[Any]:
|
|
37
|
+
|
|
38
|
+
grouped_items = _build_grouped_runtime_items(config)
|
|
39
|
+
|
|
40
|
+
if not grouped_items:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
target_alpha = normalize_target_alpha(getattr(config, "psycho_target_alpha", None))
|
|
45
|
+
except Exception:
|
|
46
|
+
target_alpha = normalize_target_alpha(None)
|
|
47
|
+
|
|
48
|
+
return build_dimension_psychometric_plan(
|
|
49
|
+
grouped_items=grouped_items,
|
|
50
|
+
target_alpha=target_alpha,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_joint_psychometric_answer_plan(config: ExecutionConfig) -> Optional[Any]:
|
|
55
|
+
cached = getattr(config, "joint_psychometric_answer_plan", None)
|
|
56
|
+
if cached is not None:
|
|
57
|
+
return cached
|
|
58
|
+
plan = build_joint_psychometric_answer_plan(config)
|
|
59
|
+
config.joint_psychometric_answer_plan = plan
|
|
60
|
+
return plan
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def provider_run_context(
|
|
65
|
+
config: ExecutionConfig,
|
|
66
|
+
*,
|
|
67
|
+
state: Optional[ExecutionState] = None,
|
|
68
|
+
thread_name: str = "",
|
|
69
|
+
psycho_plan: Optional[Any] = None,
|
|
70
|
+
) -> Iterator[Optional[Any]]:
|
|
71
|
+
|
|
72
|
+
persona = generate_persona()
|
|
73
|
+
set_current_persona(persona)
|
|
74
|
+
_reset_answer_context()
|
|
75
|
+
reset_tendency()
|
|
76
|
+
reset_consistency_context(config.answer_rules, list((config.questions_metadata or {}).values()))
|
|
77
|
+
|
|
78
|
+
resolved_plan = psycho_plan
|
|
79
|
+
fallback_plan: Optional[Any] = None
|
|
80
|
+
joint_sample_plan: Optional[Any] = None
|
|
81
|
+
reserved_sample_index: Optional[int] = None
|
|
82
|
+
if resolved_plan is None:
|
|
83
|
+
fallback_plan = build_psychometric_plan_for_run(config)
|
|
84
|
+
joint_answer_plan = ensure_joint_psychometric_answer_plan(config)
|
|
85
|
+
if joint_answer_plan is not None and state is not None:
|
|
86
|
+
reserved_sample_index = state.peek_reserved_joint_sample(thread_name)
|
|
87
|
+
if reserved_sample_index is not None:
|
|
88
|
+
joint_sample_plan = joint_answer_plan.build_sample_plan(reserved_sample_index)
|
|
89
|
+
else:
|
|
90
|
+
logging.warning("线程[%s]存在联合信效度计划但未预留样本槽位,已回退常规逻辑", thread_name or "Worker-?")
|
|
91
|
+
if joint_sample_plan is not None and fallback_plan is not None:
|
|
92
|
+
resolved_plan = CombinedPsychometricPlan(primary=joint_sample_plan, fallback=fallback_plan)
|
|
93
|
+
elif joint_sample_plan is not None:
|
|
94
|
+
resolved_plan = joint_sample_plan
|
|
95
|
+
else:
|
|
96
|
+
resolved_plan = fallback_plan
|
|
97
|
+
|
|
98
|
+
if joint_sample_plan is not None:
|
|
99
|
+
diagnostics = dict(getattr(joint_sample_plan, "diagnostics_by_dimension", {}) or {})
|
|
100
|
+
active_dimensions = [
|
|
101
|
+
name
|
|
102
|
+
for name, diagnostic in diagnostics.items()
|
|
103
|
+
if not bool(getattr(diagnostic, "skipped", False))
|
|
104
|
+
]
|
|
105
|
+
logging.debug(
|
|
106
|
+
"本轮启用联合信效度计划:样本槽位=%d,维度数=%d,锁定题目数=%d,目标α=%.2f,维度=%s",
|
|
107
|
+
int(reserved_sample_index or 0) + 1,
|
|
108
|
+
len(active_dimensions),
|
|
109
|
+
len(getattr(joint_sample_plan, "choices", {}) or {}),
|
|
110
|
+
float(getattr(config, "psycho_target_alpha", 0.85) or 0.85),
|
|
111
|
+
",".join(active_dimensions[:5]) if active_dimensions else "无",
|
|
112
|
+
)
|
|
113
|
+
for diagnostic in diagnostics.values():
|
|
114
|
+
if bool(getattr(diagnostic, "skipped", False)):
|
|
115
|
+
continue
|
|
116
|
+
if not bool(getattr(diagnostic, "degraded_for_ratio", False)):
|
|
117
|
+
continue
|
|
118
|
+
logging.warning(
|
|
119
|
+
"维度[%s]已保比例优先,实际α=%.3f 低于目标α=%.3f,主方向=%s,反向题=%d,锚点明确=%s",
|
|
120
|
+
getattr(diagnostic, "dimension", ""),
|
|
121
|
+
float(getattr(diagnostic, "actual_alpha", 0.0) or 0.0),
|
|
122
|
+
float(getattr(diagnostic, "target_alpha", 0.0) or 0.0),
|
|
123
|
+
str(getattr(diagnostic, "anchor_direction", "center") or "center"),
|
|
124
|
+
int(getattr(diagnostic, "reverse_item_count", 0) or 0),
|
|
125
|
+
"否" if bool(getattr(diagnostic, "ambiguous_anchor", False)) else "是",
|
|
126
|
+
)
|
|
127
|
+
elif resolved_plan is not None:
|
|
128
|
+
dimension_count = len(getattr(resolved_plan, "plans", {}) or {})
|
|
129
|
+
plan_names = list((getattr(resolved_plan, "plans", {}) or {}).keys())
|
|
130
|
+
if plan_names == [GLOBAL_RELIABILITY_DIMENSION]:
|
|
131
|
+
dimension_summary = "全局未分组问卷"
|
|
132
|
+
else:
|
|
133
|
+
dimension_summary = ",".join(plan_names[:5]) if plan_names else "无"
|
|
134
|
+
logging.info(
|
|
135
|
+
"本轮启用心理测量计划:维度数=%d,题目数=%d,目标α=%.2f,维度=%s",
|
|
136
|
+
dimension_count,
|
|
137
|
+
len(getattr(resolved_plan, "items", []) or []),
|
|
138
|
+
float(getattr(config, "psycho_target_alpha", 0.85) or 0.85),
|
|
139
|
+
dimension_summary,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
yield resolved_plan
|
|
144
|
+
finally:
|
|
145
|
+
reset_persona()
|
|
146
|
+
__all__ = [
|
|
147
|
+
"build_psychometric_plan_for_run",
|
|
148
|
+
"ensure_joint_psychometric_answer_plan",
|
|
149
|
+
"provider_run_context",
|
|
150
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Iterable, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuntimeActionKind(str, Enum):
|
|
9
|
+
PAUSE_RUN = "pause_run"
|
|
10
|
+
SHOW_MESSAGE = "show_message"
|
|
11
|
+
CONFIRM_ENABLE_RANDOM_IP = "confirm_enable_random_ip"
|
|
12
|
+
SET_RANDOM_IP_ENABLED = "set_random_ip_enabled"
|
|
13
|
+
REFRESH_RANDOM_IP_COUNTER = "refresh_random_ip_counter"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RuntimeActionRequest:
|
|
18
|
+
kind: RuntimeActionKind
|
|
19
|
+
title: str = ""
|
|
20
|
+
message: str = ""
|
|
21
|
+
level: str = "info"
|
|
22
|
+
reason: str = ""
|
|
23
|
+
enabled: Optional[bool] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RuntimeActionResult:
|
|
28
|
+
actions: Tuple[RuntimeActionRequest, ...] = ()
|
|
29
|
+
should_stop: bool = False
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def empty(cls) -> "RuntimeActionResult":
|
|
33
|
+
return cls()
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_actions(
|
|
37
|
+
cls,
|
|
38
|
+
actions: Iterable[RuntimeActionRequest],
|
|
39
|
+
*,
|
|
40
|
+
should_stop: bool = False,
|
|
41
|
+
) -> "RuntimeActionResult":
|
|
42
|
+
return cls(tuple(actions or ()), bool(should_stop))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_runtime_action_result(value: object) -> RuntimeActionResult:
|
|
46
|
+
if isinstance(value, RuntimeActionResult):
|
|
47
|
+
return value
|
|
48
|
+
if value is None:
|
|
49
|
+
return RuntimeActionResult.empty()
|
|
50
|
+
if isinstance(value, RuntimeActionRequest):
|
|
51
|
+
return RuntimeActionResult((value,))
|
|
52
|
+
if isinstance(value, (list, tuple)):
|
|
53
|
+
actions = tuple(item for item in value if isinstance(item, RuntimeActionRequest))
|
|
54
|
+
return RuntimeActionResult(actions)
|
|
55
|
+
return RuntimeActionResult.empty()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"RuntimeActionKind",
|
|
60
|
+
"RuntimeActionRequest",
|
|
61
|
+
"RuntimeActionResult",
|
|
62
|
+
"ensure_runtime_action_result",
|
|
63
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StopSignalLike(Protocol):
|
|
7
|
+
def is_set(self) -> bool: ...
|
|
8
|
+
|
|
9
|
+
def set(self) -> None: ...
|
|
10
|
+
|
|
11
|
+
def wait(self, timeout: Optional[float] = None) -> bool: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["StopSignalLike"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__: list[str] = []
|