File size: 7,921 Bytes
4a4df15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3035446
4a4df15
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""Interactive installer runs INSIDE Docker; no host Python/venv required."""
from __future__ import annotations
import argparse
import getpass
from pathlib import Path
from .configuration import DATA, SECRETS, LocalConfig, DEFAULT_SPACE, load_config, save_config, repo_id
from .storage import LocalStore


def ask(text: str, default: str = '') -> str:
    value=input(f'{text}' + (f' [{default}]' if default else '') + ': ').strip()
    return value or default

def yes(text: str, default: bool = False) -> bool:
    while True:
        v=ask(text+' (y/n)','y' if default else 'n').lower()
        if v in ('y','yes'): return True
        if v in ('n','no'): return False
        print('Enter y or n.')

def password() -> str:
    while True:
        p=getpass.getpass('Local password (12+ characters, hidden): ')
        q=getpass.getpass('Repeat password: ')
        if p==q and 12<=len(p)<=512:
            return p
        print('Passwords must match and contain 12-512 characters.')

def new_user(store, *, admin=False):
    while True:
        username=ask('Local username').lower()
        try:
            uid=store.create_user(username,password(),admin=admin)
            print(f'Created local account: {username}')
            return uid
        except ValueError as exc:
            print(exc)

def configure_token(store,uid):
    u=store.by_id(uid)
    print(f"HF settings for {u['username']} (not your local password).")
    print('No token is needed for CPU mode. You may skip now and add your personal token in the app.')
    token=getpass.getpass('Personal HF token (hidden; Enter to keep/skip): ').strip()
    consent=yes('Allow sending source excerpts, question, rubric and transcript to the configured HF Space?',bool(u['remote_consent']))
    store.preferences(uid,u['backend'],consent,token)

def manage(store,config):
    # This CLI requires Docker/host access, which is already administrative.
    # Still authenticate a local admin when account login is enabled.
    if config.accounts:
        name=ask('Local administrator username')
        if not store.authenticate(name,getpass.getpass('Administrator password: ')):
            raise SystemExit('Administrator sign-in failed.')
        admin=store.user(name)
        if not admin['admin']:
            raise SystemExit('Administrator account required.')
    else:
        admin=store.user('local')
    while True:
        print('\nAccounts: '+', '.join(u['username']+(' (admin)' if u['admin'] else '') for u in store.list_users()))
        op=ask('Action: add / password / token / delete / done','done')
        if op=='done': return
        if op=='add':
            uid=new_user(store,admin=yes('Administrator account?'))
            if yes('Configure this account HF token now?'): configure_token(store,uid)
            continue
        target=store.user(ask('Target username'))
        if not target:
            print('Account not found.');continue
        try:
            if op=='password':
                store.reset_password(target['id'],password())
                print('Password reset. Restart the app to invalidate existing sign-ins.')
            elif op=='token': configure_token(store,target['id'])
            elif op=='delete':
                if yes('Permanently delete this account, saved courses and history?'):
                    store.delete_user(admin['id'],target['id'])
                    print('Account deleted. Restart the app to end its existing sessions.')
            else: print('Unknown action.')
        except ValueError as exc: print(exc)

def main():
    parser=argparse.ArgumentParser()
    parser.add_argument('--manage-users',action='store_true')
    args=parser.parse_args()
    store=LocalStore(DATA,SECRETS)
    config=load_config() if (DATA/'settings.json').exists() else LocalConfig()
    if args.manage_users:
        return manage(store,config)
    print('\nDOCUMENT EXAM TRAINER - LOCAL SETUP\n')
    print('Data and encrypted tokens will be saved in separate named Docker volumes.')
    print('The application port is restricted to localhost by default. No public access is configured.')
    if (DATA/'settings.json').exists() and not yes('An installation exists. Change its settings?',False):
        print('Keeping existing configuration and all saved data.');return
    config.accounts=yes('Require local username/password login? (recommended for shared computers)',config.accounts)
    if config.accounts:
        admins=[u for u in store.list_users() if u['admin'] and u['enabled'] and u['username']!='local']
        if not admins:
            print('Create the first local administrator account.')
            uid=new_user(store,admin=True)
        else:
            uid=admins[0]['id']
            print('Keeping existing accounts and passwords.')
        while yes('Add another local account?'):
            new_user(store)
    else:
        print('Single-user mode has no login. Anyone who can use this computer/localhost port can access its local profile.')
        u=store.user('local')
        uid=u['id'] if u else store.create_user('local','',admin=True,single=True)
    while True:
        choice=ask('Default inference: 1 = remote HF Space, 2 = local CPU', '1' if config.default_backend=='remote_hf' else '2')
        if choice in ('1','2'): break
    config.default_backend='remote_hf' if choice=='1' else 'local_cpu'
    while True:
        try:
            config.remote_space=repo_id(ask('Remote Space (used only in remote mode)',config.remote_space))
            break
        except ValueError as exc: print(exc)
    config.allow_anonymous=yes('Allow remote requests without a personal HF token? Anonymous quotas are more restrictive.',config.allow_anonymous)
    print('\nCPU models: 1 = compact Qwen3-1.7B (both tasks), 2 = Qwen3-4B (both tasks), 3 = custom public HF model IDs.')
    print('CPU inference is slower and the compact model is less capable than the hosted 14B evaluator.')
    print('Planning estimates: allow roughly 12-16 GB RAM for compact CPU mode, 24+ GB for 4B; actual usage varies. No quantization is enabled for the CPU LLM.')
    choice=ask('CPU model preset','1')
    if choice=='1': config.cpu_question_model=config.cpu_evaluation_model='Qwen/Qwen3-1.7B'
    elif choice=='2': config.cpu_question_model=config.cpu_evaluation_model='Qwen/Qwen3-4B'
    elif choice=='3':
        config.cpu_question_model=repo_id(ask('CPU question model',config.cpu_question_model))
        config.cpu_evaluation_model=repo_id(ask('CPU evaluation model',config.cpu_evaluation_model))
        print('Custom checkpoints must work with Transformers/Qwen-style chat templates; not every model is compatible.')
    else: raise ValueError('Unknown CPU model preset.')
    config.cpu_threads=int(ask('CPU threads',str(config.cpu_threads)))
    config.offline_models=yes('Use cached models only? Choose no for the first installation.',config.offline_models)
    if yes('Configure the first account HF token now?',config.default_backend=='remote_hf'):
        configure_token(store,uid)
    if config.accounts:
        others = [u for u in store.list_users() if u['id'] != uid and u['username'] != 'local' and u['enabled']]
        for other in others:
            if yes(f"Configure HF token/consent for {other['username']} now?"):
                configure_token(store, other['id'])
    save_config(config)
    print('\nSetup saved. No passwords or HF tokens were written into the image or compose file.')
    print('Open http://localhost:7860 after the container starts. Each user can change backend and HF token in Account & inference.')
    print('Models download on first use. CPU-only mode sends no documents/answers to a remote inference service.')

if __name__=='__main__':
    try: main()
    except (EOFError,KeyboardInterrupt): raise SystemExit('\nSetup cancelled. Rerun the installer to finish.')
    except ValueError as exc: raise SystemExit(str(exc))